# Rule Builder

> A React rule builder for if-this-then-that automations: a trigger, conditions and ordered actions as sentence cards, read back as one sentence.

- Type: React component (AI Kit)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add rule-builder`
- Docs: https://wingo-ui.com/components/rule-builder

## Interaction

Pick a trigger from the dashed card, then press any step card to edit it: on a desktop the editor opens under the card, on a phone it rises as a bottom sheet. Edits are drafts: Apply, or Enter in a one-line field, saves them; Cancel, Escape or closing the sheet throws them away, and Apply stays dimmed until the required fields are filled (pressing it anyway points at them). Add conditions from the trigger's fields and choose whether all or any must match; add actions from the catalog and reorder them by dragging the grip (on a phone only the grip drags, the rest of the card scrolls the page), with Alt+Up or Alt+Down on a card, or with Space, the arrows and Space again on the grip. Each card's more button offers Edit, Duplicate, Move up, Move down and Remove (the trigger's offers Change trigger). Up, Down, Home and End move between the cards, Tab reaches a card's grip and more button. The header switch turns the rule on or off, and a stopped rule's cards dim; Test runs onTest and shows each action's outcome under the summary, which reads the whole rule as one sentence.

## Installation

```bash
npx wingo-ui@latest add rule-builder
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/rule-builder` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/rule-builder.tsx`

npm dependencies: `npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Multi Select](https://wingo-ui.com/components/multi-select) ([Markdown](https://wingo-ui.com/components/multi-select.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { FileWarning, Mail, Tag } from "lucide-react"
import {
  RuleBuilder,
  describeRule,
  type RuleActionDef,
  type RuleTriggerDef,
  type RuleValue,
} from "@/components/ui/rule-builder"

const days = (count: number) => (count === 1 ? "1 day" : `${count} days`)

const triggers: RuleTriggerDef[] = [
  {
    type: "invoice-overdue",
    label: "Invoice overdue",
    icon: <FileWarning />,
    params: [{ id: "days", label: "After", type: "days", required: true, defaultValue: 30 }],
    fields: [
      { id: "amount", label: "Amount", type: "currency" },
      {
        id: "city",
        label: "City",
        type: "select",
        options: [
          { value: "Austin", label: "Austin" },
          { value: "Portland", label: "Portland" },
        ],
      },
    ],
    summary: (params) => `an invoice is **${days(Number(params.days))}** overdue`,
  },
]

const actions: RuleActionDef[] = [
  {
    type: "send-email",
    label: "Send email",
    icon: <Mail />,
    tone: "info",
    params: [
      {
        id: "template",
        label: "Template",
        type: "select",
        required: true,
        options: [{ value: "final-notice", label: "Final notice" }],
      },
    ],
    summary: () => "send the **“Final notice”** email to the client",
  },
  {
    type: "add-tag",
    label: "Add tag",
    icon: <Tag />,
    params: [{ id: "tag", label: "Tag", type: "text", required: true }],
    summary: (params) => `add the **${params.tag}** tag`,
  },
]

export function OverdueRule({
  initial,
  onSave,
}: {
  initial: RuleValue
  onSave: (rule: RuleValue, sentence: string) => void
}) {
  const [rule, setRule] = useState(initial)
  return (
    <RuleBuilder
      title="Final notice after 30 days"
      triggers={triggers}
      actionTypes={actions}
      value={rule}
      onValueChange={(next) => {
        setRule(next)
        onSave(next, describeRule(next, { triggers, actions }))
      }}
      onTest={async () => ({ matched: true, message: "Invoice #2026-0371 would get the final notice." })}
      labels={{ test: "Dry run", enabled: "On" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `triggers` | `RuleTriggerDef[]` |  | The trigger catalog: { type, label, description?, icon?, group?, params?, fields?, summary? }. fields are the lib/filters fields the conditions can test (an invoice, a deal, a lead); summary(params) says the trigger in a sentence with values wrapped in **, like "an invoice is **30 days** overdue". |
| `actionTypes` | `RuleActionDef[]` |  | The action catalog: { type, label, description?, icon?, group?, tone?, params?, summary?, multiple? }. tone colors the icon tile; multiple: false allows the action once per rule, so it leaves the picker once used. Named actionTypes because actions is the value's list. |
| `title?` | `ReactNode` | `""` | The rule's name in the header. It also names the switch, so a screen reader hears which rule turns on or off. |
| `value?` | `RuleValue` |  | The rule: { trigger: { type, params } \| null, conditions: FilterGroup, actions: { id, type, params }[] }. conditions is the lib/filters group a saved view or filter-builder uses, root level only. |
| `defaultValue?` | `RuleValue` |  | The starting rule while uncontrolled; an empty rule (createRule()) when left out. |
| `onValueChange?` | `(value: RuleValue) => void` |  | Every applied change: an editor's Apply, a picked trigger, an added, duplicated, moved or removed step, the all / any switch. Drafts in an open editor never reach it. |
| `enabled?` | `boolean` |  | Controlled state of the header switch; pair it with onEnabledChange. |
| `defaultEnabled?` | `boolean` | `true` | Whether the rule starts switched on. A switched off rule's cards dim to 60%. |
| `onEnabledChange?` | `(enabled: boolean) => void` |  | Fires when the header switch flips. |
| `readOnly?` | `boolean` | `false` | Summaries only: cards without editors, menus, grips or add buttons, and a switch that shows its state. Test still works. |
| `disabled?` | `boolean` | `false` | Dims and locks everything: cards, menus, grips, add buttons, the switch and Test. |
| `onTest?` | `(value: RuleValue) => Promise<RuleTestResult>` |  | Shows the Test button. While the promise runs the button spins; the result ({ matched, message?, steps?: { actionId, ok, message? }[] }) appears under the summary with each action's outcome, and any edit clears it. A rejection shows labels.testFailed. |
| `allowOr?` | `boolean` | `true` | Lets the conditions match any instead of all: an all / any switch appears once there are two conditions. Off, conditions always join with and. |
| `maxActions?` | `number` | `0` | The most actions a rule may have; the add button hides at the limit. A value below the current count removes nothing. 0 means no limit. |
| `validate?` | `"always" \| "touched" \| "never"` | `"touched"` | When issues show on the cards (a red ring and a line saying what is missing): always, only after a step was edited once, or never (the app validates with validateRule). |
| `responsive?` | `boolean` | `true` | Below 768px the step editors, the trigger, field and action pickers and the card menus open as bottom sheets. Off, phones get the desktop editor under the card. |
| `showSummary?` | `boolean` | `true` | The dashed box that reads the whole rule as one sentence, values in bold. |
| `summaryPosition?` | `"top" \| "bottom"` | `"bottom"` | Where the sentence sits: under the header or after the actions. |
| `size?` | `"sm" \| "md"` | `"md"` | Step cards 48 / 56px tall with 28 / 32px icon tiles, and sm / md fields in the desktop editors. On touch screens every card is at least 56px. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the section pills (When, If, Then), the rails between them, the action numbers and the switch. neutral uses foreground tints. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone for the pills, rails, numbers and switch. Leave empty to use the tone. |
| `currency?` | `string` | `"USD"` | ISO 4217 code for currency params and currency fields that do not set their own; amounts read like $5,000. |
| `locale?` | `string` | `""` | Formats numbers, money and dates in cards, fields and the sentence (en-US: $5,000). Empty uses the UIProvider's locale, then the browser's. |
| `labels?` | `RuleBuilderLabelsInput` |  | Every visible and spoken string, merged over DEFAULT_RULE_BUILDER_LABELS: when, if, then, chooseTrigger, chooseAction, chooseField, addCondition, addAction, all, any, matchAll, matchAny, match, noConditions, noActions, edit, duplicate, moveUp, moveDown, remove, change, more, apply, cancel, required, back, enabled, test, testing, matched, notMatched, testFailed, stepOk, stepFailed, dismiss, summary, summaryEmpty, trigger, condition, field, operator, value, from, to, choose, search, noResults, daysUnit, days(count, formatted), unknownTrigger, unknownAction, unknownField, fieldUnavailable, incomplete, missing(label), sentence { when, if, then, and, or, comma, nothing }, operators (per filter operator), dragHandle(label), dragInstructions and announce { added, removed, moved, pickedUp, cancelled, pruned }. |
| `classNames?` | `Partial<Record<"header" \| "title" \| "enabled" \| "test" \| "section" \| "sectionLabel" \| "rail" \| "steps" \| "step" \| "stepButton" \| "stepIcon" \| "stepNumber" \| "stepSummary" \| "stepIssue" \| "stepMenu" \| "stepHandle" \| "joiner" \| "editor" \| "editorFooter" \| "combinator" \| "add" \| "empty" \| "summary" \| "testResult" \| "sheet", string>>` |  | Extra classes for the inner parts; step is the card, stepButton the stretched button that opens its editor, sheet the phone editor. |
| `className?` | `string` |  | Extra classes for the root, merged last. Override the tone vars here, e.g. [--tone-soft:...]. |

## Parts

### describeRule (function)

The rule as one plain sentence, the same one the summary box shows: "When an invoice is 30 days overdue, if Amount ≥ $5,000, then …". Only complete conditions are read. Use it for notifications, logs and the automation list. Signature: (value: RuleValue, options: { triggers; actions; locale?; currency?; labels? }) => string.

### validateRule (function)

What keeps the rule from running, as { path: "trigger" | `conditions.${id}` | `actions.${id}`, message }: a missing or unknown trigger, incomplete conditions or conditions on fields the trigger lacks, unknown actions and required params left empty. Signature: (value: RuleValue, options: { triggers; actions; labels? }) => RuleIssue[].

### createRule (function)

An empty rule (no trigger, an and group with no conditions, no actions). Call it in handlers or state initializers. Signature: (overrides?: Partial<RuleValue>) => RuleValue.

### DEFAULT_RULE_BUILDER_LABELS (constant)

The English strings, to spread and override. Type: RuleBuilderLabels.

## Examples

- **Empty rule**: createRule(): pick a trigger from the dashed card; required params open its editor first.
- **Any of the conditions**: allowOr with any selected: one of three conditions is enough, and the joiners say or.
- **Read only in a list**: readOnly shows summaries only, for an Automations list; Edit brings the editors back.
- **Invalid steps**: validate="always": an e-mail without a template and a condition without a value, ringed in red.
- **On a phone**: The playground at 390px: every card opens its editor as a bottom sheet with Apply and Cancel.
- **Tone and color**: tone="brand" or info colors the pills, rails, numbers and switch; a custom color replaces the tone.
- **Built-in labels, euros**: No labels passed: the built-in strings, size sm, amounts in euros for en-US.

## See also

- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))
- [Filter Builder](https://wingo-ui.com/components/filter-builder) ([Markdown](https://wingo-ui.com/components/filter-builder.md))
- [Automation Builder Page](https://wingo-ui.com/components/automation-builder-page) ([Markdown](https://wingo-ui.com/components/automation-builder-page.md))
- [Workflow Canvas](https://wingo-ui.com/components/workflow-canvas) ([Markdown](https://wingo-ui.com/components/workflow-canvas.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))

---

Canonical: https://wingo-ui.com/components/rule-builder
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
