Visibility

Conditional visibility

Show or hide elements on a page based on the values the user has entered. Add a visibility map to ctx.ui.page() or ctx.ui.iterator(). Each key is the element to target. Each value is a rule that must be true for the element to show.

flows/createOrder.ts
const order = await ctx.ui.page("orderDetails", {
  title: "Order details",
  content: [
    ctx.ui.inputs.boolean("hasDiscount", { label: "Apply a discount?" }),
    ctx.ui.inputs.text("discountCode", {
      label: "Discount code",
      validate: (code) =>
        code.startsWith("D-") ? true : "Discount codes start with D-",
    }),
    ctx.ui.display.banner({
      key: "discountBanner",
      title: "Discount",
      description: "A discount will be applied to this order",
    }),
    ctx.ui.iterator("lines", {
      content: [
        ctx.ui.inputs.number("quantity", { label: "Quantity" }),
        ctx.ui.inputs.text("reason", { label: "Reason for zero quantity" }),
      ],
      visibility: {
        reason: "inputs.quantity <= 0",
      },
    }),
    ctx.ui.display.banner({
      key: "zeroLines",
      title: "Zero quantities",
      description: "Some lines have no quantity",
      mode: "warning",
    }),
  ],
  visibility: {
    discountCode: "inputs.hasDiscount == true",
    discountBanner:
      'inputs.hasDiscount == true && inputs.discountCode != null && inputs.discountCode != ""',
    zeroLines: "ANY(inputs.lines.quantity <= 0)",
  },
});
 
return {
  hasDiscount: order.hasDiscount,
  discountCode: order.discountCode ?? null,
  lines: order.lines,
};

In this example:

  • The discount code field only shows once the user ticks Apply a discount?.
  • The discount banner shows once a code has been entered.
  • On each order line, the reason field only shows when that line's quantity is zero or less.
  • The warning banner shows when any line has a quantity of zero or less.

The console re-evaluates rules as the user types. When the page is submitted, Keel evaluates the rules again on the server. Hidden inputs are not validated and are removed from the result.

Targeting elements

Keys in the visibility map refer to elements on the same page.

ElementTargeted by
Inputs (ctx.ui.inputs.*), select elements, and pick listsname
Iteratorsname
Display elements (banner, code, divider, file, grid, header, image, keyValue, list, markdown, summary, table) and printkey

Display elements don't have a name, so give them a key to target them:

ctx.ui.display.markdown({
  key: "deliveryNote",
  content: "Deliveries over 1000 kg are scheduled by the warehouse team.",
});

A key is only used for writing rules. It isn't shown in the UI and isn't sent to the console. A display element without a key can't be targeted.

A key that doesn't match an element on the page is a type error. You can't target:

Writing rules

Rules are written in a small subset of CEL (opens in a new tab). Refer to the page's inputs through the inputs namespace:

visibility: {
  discountCode: "inputs.hasDiscount == true",
  approvalNote: 'inputs["order-total"] >= 1000',
},

Use the bracket form inputs["name"] for input names that contain dashes, spaces or dots, and for names that are CEL keywords such as if or in. ctx is reserved and can't be used as an input name.

Operators

OperatorMeaningExample
== !=Equal, not equalinputs.status == "Draft"
< <= > >=Compare numbers or stringsinputs.quantity >= 10
&& ||And, orinputs.isExpress == true && inputs.weight > 20
!Not!inputs.isExpress
in [...]Is one of a list of literalsinputs.region in ["EU", "UK"]
( )Grouping!(inputs.quantity == 1)

! binds tighter than comparisons. !inputs.quantity == 1 is read as (!inputs.quantity) == 1, so wrap the comparison in parentheses to negate it.

Literals are strings in single or double quotes, numbers (10, 2.5, -1), true, false and null. The list after in must contain at least one literal and can't contain null.

Functions

FunctionReturnsExample
size(field)Length of a text or list inputsize(inputs.notes) > 0
STARTSWITH(field, "text")Whether the text starts with a valueSTARTSWITH(inputs.sku, "PRD-")
ENDSWITH(field, "text")Whether the text ends with a valueENDSWITH(inputs.email, "@example.com")
CONTAINS(field, "text")Whether the text contains a valueCONTAINS(inputs.notes, "urgent")
MATCHES(field, "regex")Whether the text matches a regular expressionMATCHES(inputs.postcode, '^[A-Z]{1,2}[0-9]')

The first argument must be an input and the second must be a single quoted string. To work with iterators, use COUNT, SUM, MIN, MAX, ANY and ALL.

Types

Rules are checked against each input's type:

  • Boolean, number and text inputs compare with values of the same type. inputs.quantity == "10" is an error because a number is compared with text. Values are never converted from one type to another.
  • Select inputs take the type of their options. A text literal compared with a select input must be one of its option values, so inputs.size == "XL" is an error if XL isn't an option.
  • Date picker values are ISO 8601 strings. See Dates.
  • List inputs, such as a multi-mode scan, a data grid or a multi-select table, work with size(...) or compare with null.
  • File, signature, image capture (single mode), single-select table and pick list inputs can only be compared with null.

Any input can be compared with null.

Iterators

Rules on an iterator are evaluated separately for each row. Put them in the iterator's own visibility map. inputs refers to the inputs of the same row:

ctx.ui.iterator("lines", {
  content: [
    ctx.ui.select.one("condition", {
      label: "Condition",
      options: ["New", "Damaged"],
    }),
    ctx.ui.inputs.text("damageNotes", { label: "Damage notes" }),
    ctx.ui.display.banner({
      key: "damageWarning",
      title: "Damaged stock",
      description: "Damaged stock is quarantined on receipt",
      mode: "warning",
    }),
  ],
  visibility: {
    damageNotes: 'inputs.condition == "Damaged"',
    damageWarning: 'inputs.condition == "Damaged"',
  },
});

Row rules can only see the inputs of their own row. Referring to an input that sits on the page gives an Unknown input error. Row rules can't use aggregate functions.

To control a page element based on the iterator's rows, use an aggregate function in the page's visibility:

FunctionReturnsExample
COUNT(iterator)Number of rowsCOUNT(inputs.lines) > 0
SUM(iterator.field)Sum of a number field across rowsSUM(inputs.lines.quantity) >= 100
MIN(iterator.field)Smallest value of a number fieldMIN(inputs.lines.unitPrice) < 1
MAX(iterator.field)Largest value of a number fieldMAX(inputs.lines.quantity) > 500
ANY(condition)Whether any row matchesANY(inputs.lines.quantity <= 0)
ALL(condition)Whether every row matchesALL(inputs.lines.condition == "New")

Refer to a row field as inputs.<iterator>.<field>. Inside ANY and ALL, other inputs.<name> references still refer to page inputs, so you can compare each row with a value on the page:

visibility: {
  overPackWarning: "ANY(inputs.lines.quantity > inputs.packSize)",
},

Each aggregate can refer to only one iterator. You can combine aggregates with && and ||.

A page rule can also hide a whole iterator by its name:

visibility: {
  lines: "inputs.hasLines == true",
},

Hidden inputs

When the page is submitted, Keel evaluates each rule against the submitted data. Hidden inputs are handled as follows:

  • They aren't validated. Their validate function doesn't run, even if they aren't marked optional.
  • Their values are removed from the result and from the stored step value. If an iterator is hidden, its whole array is removed and neither its rows nor its validate function are checked.
  • The page's and iterator's validate functions receive the data with hidden values already removed.

Because any target might be hidden, its type in the result includes undefined:

const order = await ctx.ui.page("orderDetails", {
  content: [
    ctx.ui.inputs.boolean("hasDiscount", { label: "Apply a discount?" }),
    ctx.ui.inputs.text("discountCode", { label: "Discount code" }),
  ],
  visibility: {
    discountCode: "inputs.hasDiscount == true",
  },
});
 
order.hasDiscount; // boolean
order.discountCode; // string | undefined

Rows work the same way. A reason field targeted by the iterator's rules is typed string | undefined on each row.

When you return the page's data from a flow or store it on a model, convert undefined to null with ?? null.

Nulls and errors

A rule shows its element only when it evaluates to true. If a rule evaluates to null or fails to evaluate, the element is hidden. This happens when:

  • an input or row field that hasn't been filled in is compared with <, <=, > or >=
  • size() or a text function such as CONTAINS() is called on an input that hasn't been filled in
  • MIN() or MAX() runs over an iterator with no rows

Empty iterators follow these rules:

  • COUNT() returns 0.
  • SUM() returns 0.
  • ANY() returns false.
  • ALL() returns true.

Editor checking

Rules are type-checked as you write them. A mistake shows up as a TypeScript error on that rule's property. Your editor also suggests input names as you type.

visibility: {
  // Type '"inputs.pakcs >= 10"' is not assignable to type '"Unknown input 'pakcs'"'
  packWarning: "inputs.pakcs >= 10",
},

Rules are only checked when you write them inline as string literals. Don't build rules from variables or template strings.

Common patterns

Checking that an input is set

visibility: {
  // Text inputs
  notesPreview: 'inputs.notes != null && inputs.notes != ""',
  // List inputs, such as a multi-mode scan
  scanSummary: "inputs.serials != null && size(inputs.serials) > 0",
  // Number, boolean and other inputs
  totalBanner: "inputs.quantity != null",
},

Matching select options

visibility: {
  customsReference: 'inputs.destination in ["US", "CA", "CH"]',
},

Numeric thresholds

visibility: {
  approvalReason: "inputs.orderTotal >= 10000",
},

Dates

Date picker values are ISO 8601 strings, so compare them with ISO strings. Ordering comparisons work because ISO strings sort in date order:

visibility: {
  backdateReason: 'inputs.deliveryDate < "2026-01-01"',
},

Avoid == with a date-only string. A value that includes a time won't equal "2026-01-01".

Limitations

Rules support only the operators and functions listed above. The following aren't supported:

  • arithmetic such as inputs.quantity * inputs.unitPrice > 100
  • the ternary operator ? :
  • has() and other CEL macros
  • JavaScript-style methods such as inputs.sku.startsWith("PRD-"). Use STARTSWITH(inputs.sku, "PRD-") instead.
  • escape sequences in strings. To include a quote character, wrap the string in the other kind of quote.
⚠️

On submit, every rule is evaluated against the data as the user submitted it, before any hidden values are removed. If rule A hides input B and rule C depends on B, the server still sees B's submitted value when it evaluates C. The result can differ from what the console showed. Make each rule depend directly on the input that controls it, rather than chaining rules through inputs that can themselves be hidden.