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

Import from the workspace package:

import { RuleBuilder, RuleSchema, RuleFieldDef, RuleFieldType, RuleValue, RuleNode, RuleOperator, RuleQuantifier, RuleBuilderLabels } 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.

Localized (Russian) chrome

The same builder with the optional `labels` prop. Field and option labels come from the schema; `labels` translates the component's own chrome — operators, quantifier pills + captions, empty state, add buttons, the rule counter, and command-palette placeholders. Glyph operators (≥ ≤ = ≠ > <) stay as-is.

Сравнение·2 правил
должны выполняться все условия ниже
дней
·
import {
  RuleBuilder,
  type RuleBuilderLabels,
  type RuleSchema,
  type RuleValue,
} from "@8maverik8/design";

const ruLabels: Partial<RuleBuilderLabels> = {
  operatorIs: "равно",
  operatorIsNot: "не равно",
  operatorContains: "содержит",
  operatorStartsWith: "начинается с",
  quantifierAll: "Все из",
  quantifierAny: "Любое из",
  quantifierNone: "Ни одно из",
  quantifierHintAll: "должны выполняться все условия ниже",
  quantifierHintAny: "должно выполняться хотя бы одно условие ниже",
  quantifierHintNone: "не должно выполняться ни одно условие ниже",
  emptyState: "Пока нет условий. Нажмите {action}, чтобы начать.",
  emptyStateAction: "+ Добавить условие",
  addCondition: "Добавить условие",
  addGroup: "Добавить группу",
  ruleCountSingular: "правило",
  ruleCountPlural: "правил",
  pickFieldTrigger: "Выберите поле",
  findFieldPlaceholder: "Поиск поля…",
  noFieldFound: "Поле не найдено.",
  pickValuePlaceholder: "Выбрать…",
};

<RuleBuilder
  schema={schema}
  value={value}
  onChange={setValue}
  title="Сравнение"
  labels={ruLabels}
/>
// Defaults are English — omit `labels` and nothing changes.

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.
  • labelsOptional `Partial<RuleBuilderLabels>` to localize every chrome string — word-operators, quantifier pills + captions, empty state, add buttons, the rule counter, and command-palette placeholders. Shallow-merged over English defaults, so override only what you need. Glyph operators (≥ ≤ = ≠ > <) stay language-neutral. Field/option labels come from the schema.

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.