designComponents

Search the design system…

Search the design system…

patterns

RuleBuilder

Schema-driven multi-condition / multi-group filter constructor — the visual answer to MailChimp segments, Linear filters, Notion database filters. UI + local state only; you bring the schema, persist the value, and decide how to evaluate it against your data.

The component is fully controlled — `value` is a JSON document you can serialize. Every field on a row (subject, operator, value, scope, unit) renders as a same-height pill so the row reads as one tightly-packed inline-flex; groups visually nest via a 2px left border, never as filled cards. Drag handle and delete appear on row-hover only.

Install

Pull this component (and its dependencies) straight into your app via the shadcn CLI:

npx shadcn@latest add https://design.oapps.io/r/rule-builder.json

Or import from the workspace package:

import { RuleBuilder, RuleSchema, RuleFieldDef, RuleFieldType, RuleValue, RuleNode, RuleOperator, RuleQuantifier } from "@8maverik8/design";

Examples

Cip-style audience builder

Live, fully interactive. Click any pill to change it, click + Add condition / Add group, change the All-of quantifier, hover any row for the drag handle and delete affordance.

Query·5 rules
all conditions below must be true
occurrenceswithin
eventsfor
days
·
import { RuleBuilder, type RuleSchema, type RuleValue } from "@8maverik8/design";

const schema: RuleSchema = {
  fields: [
    { id: "is_delinquent",   label: "Delinquency", type: { kind: "boolean" } },
    { id: "has_open_ticket", label: "Open ticket", type: { kind: "boolean" } },
    { id: "bad_csat_count",  label: "Bad CSAT",
      type: { kind: "number-window", label: "no more than",
              windowUnits: ["days","weeks","months"],
              defaultWindow: 180, defaultWindowUnit: "days" } },
    { id: "events_30d",      label: "Events (30d)",
      type: { kind: "number-scope",
              scopes: [{ id: "any-app", label: "any app" },
                       { id: "specific-app", label: "specific app" }],
              unit: "events" } },
    { id: "tenure_days",     label: "Tenure",
      type: { kind: "number", unit: "days" } },
    { id: "lifecycle_stage", label: "Lifecycle stage",
      type: { kind: "enum", options: [
              { id: "trial", label: "Trial" },
              { id: "active", label: "Active" },
              { id: "churn-risk", label: "Churn risk" },
              { id: "dormant", label: "Dormant" },
            ] } },
  ],
};

const [value, setValue] = useState<RuleValue>(initialValue);

<RuleBuilder schema={schema} value={value} onChange={setValue} />
// Persist value as JSON. Reload from JSON. That's the whole contract.

Variants

RuleFieldType.kind

  • boolean`is true` / `is not true`. No value editor.
  • numberNumeric ≥/≤/=/≠/>/<. Optional unit suffix ("days", "events").
  • number-window"{label} N {unit}" — e.g. "no more than 0 occurrences within 180 days". Use for time-windowed thresholds.
  • number-scope"≥ N {unit} for {scope}" — e.g. "≥ 100 events for any app". Use when the count is measured against a sub-entity.
  • stringFree-text value with `is / is not / contains / starts with`.
  • enumDropdown of named options. `is / is not`.

Anatomy

  • schemaDescribes the fields the user can filter by + their value types. Owned by the host product (Cip / Archi / future) — stays close to your data model.
  • valueControlled JSON — `{ quantifier, rules: RuleNode[] }`. Persist it as-is, load it back on mount.
  • onChangeFires on every edit with the next value. Caller decides debounce / save / autosave.
  • rootQuantifierOverride the top-level All / Any / None for the very first quantifier pill. Defaults to `value.quantifier`.
  • maxNestingDepthHow deep groups can nest. Default 2 (one level of nested groups). Set to 1 for a flat list.

Guidelines

  • Define the schema close to your data model and pass it in. The component is dumb about your fields — that's the point; one component, every product, every domain.
  • Treat the emitted `value` as the contract you persist and parse server-side. Same JSON shape feeds your SQL/Drizzle WHERE later.
  • Render two different RuleBuilders on one page sharing state.Each constructor is one query. If you need a saved-segment selector inside a builder, that's a custom value renderer for a `field-as-saved-segment` type — not a parallel builder.
  • Bolt extra UI inside `RuleRow` via DOM hacks.If you need a new field type (e.g. "saved segment reference", "date range"), it's a new entry in the field-type union, not a row override.
  • Style your container to constrain max-width — a typical /audiences page caps it at ~768-960px so long pill rows wrap cleanly.