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