Every feature in depth. For quick lookup tables see the Reference; for the demo app and architecture notes see Development.

Defining forms

A form definition can be written declaratively in the template or as data. Both converge on the same DynamicForm.Instance struct, so everything below the definition — validation, conditional logic, rendering, submission — behaves identically.

Declarative mode

Define fields with <:field> slots inside DynamicForm.form/1, in render order:

<DynamicForm.form id="contact-form" title="Contact Form">
  <:field type="text" name="name" label="Name" required />
  <:field type="text" name="email" input_type="email" label="Email Address"
          required format="email" />
  <:field type="dropdown" name="subject" label="Subject"
          options={[{"Support", "support"}, {"Sales", "sales"}]} />
  <:field type="comment" name="details" label="Details" />
</DynamicForm.form>

Slot attrs are snake_case and map onto the SurveyJS-style Instance fields (labeltitle, optionschoices, visible_ifvisibleIf, ...). See the Reference for every attribute.

Question types collect input: text (with input_type pass-through for email, number, ...), comment, dropdown, radiogroup, checkbox, boolean, rating, tagbox, and file. Element types render content: html, image, and custom (declarative-only, requires a slot body).

Invalid definitions — missing or duplicate names, choice fields without options, unknown types, fields referencing undeclared groups — raise ArgumentError with a descriptive message at render time.

Validation

Required fields use required (or required_if with an expression). The common validators are flattened into attrs:

<:field type="text" name="username" label="Username" required
        min_length={3} max_length={20} />
<:field type="text" name="age" input_type="number" label="Age" min={18} max={130} />
<:field type="text" name="slug" label="Slug" pattern="^[a-z-]+$" />
<:field type="text" name="email" label="Email" format="email" />

For anything the flattened attrs can't express, pass DynamicForm.Instance.Validator structs (or atom-keyed maps) directly — including custom error messages via :text:

<:field type="text" name="code" label="Code"
        validators={[%DynamicForm.Instance.Validator{type: "regex", regex: "^[A-Z]+$",
                                                     text: "Uppercase letters only"}]} />

All validation runs server-side through an Ecto changeset built from the definition. Inline field errors display once the changeset has an action — set on submit — and stay live during subsequent edits.

Conditional logic

visible_if, required_if, and enable_if accept SurveyJS expressions, evaluated live against the current form values:

{field} = 'value'        {field} notempty         {field} > 100
{a} = 'x' and {b} empty  {tags} anyof ['a', 'b']  {list} contains 'item'

Supported operators: =, ==, <>, !=, >, <, >=, <=, empty, notempty, contains, notcontains, anyof, allof, noneof, combined with and, or, and parentheses.

Hidden required questions are excluded from validation automatically, and a disabled panel (enable_if false) disables every question inside it.

Groups (panels)

Fields sharing a group attribute collect into a panel declared by a <:group> entry. The panel renders at the position of its first member field, so declaration order of the <:group> itself doesn't matter:

<:field type="boolean" name="ship" label="Ship to a different address?" />

<:group name="address" title="Shipping Address" visible_if="{ship} = true" />
<:field group="address" type="text" name="street" label="Street" required />
<:field group="address" type="text" name="city" label="City" required />

Groups support visible_if/enable_if like fields. Nested panels (panel-in-panel) are currently a data-mode-only feature.

Custom markup (slot bodies)

A <:field> body customizes rendering at three tiers.

Content blocks — an html body instead of the html string attr. The body is compile-checked HEEx, escaped by default, and can read parent assigns:

<:field type="html" name="intro">
  <h2>Welcome, {@current_user.name}</h2>
</:field>

Custom controls — the body receives the Phoenix.HTML.FormField and replaces the input, while the library still renders the label, description, and errors, and the changeset still validates the field:

<:field :let={field} type="text" name="budget" input_type="number" label="Budget" min={0}>
  <input type="range" min="0" max="1000" step="50"
         name={field.name} id={field.id} value={field.value || 0} />
</:field>

Whatever the control submits under field.name flows through validation unchanged — the same contract as <.form :let={f}>.

Fully custom elements — the body receives the Phoenix form itself (not a field), for arbitrary markup positioned within the form that reads current values:

<:field :let={form} type="custom" name="summary">
  <p>Total: {Phoenix.HTML.Form.input_value(form, :budget)}</p>
</:field>

Slot bodies are in-memory only: instances containing them JSON-encode with the bodies dropped, and declarative forms cannot round-trip through the WYSIWYG builder.

Reading the whole form from a body

DynamicForm.form_data/1 takes whichever value the body receives — the field or the form — and returns the form's current values as a map, the same shape a :change message delivers as payload.data:

%{name: "Ada", staff: [%{name: "Ada"}], rooms: [%{label: "Blue"}]}

It is always form-level, so a control inside one nested form can read another's entries. That makes cross-referencing self-contained: no messages to the parent, no state held outside the form, and renames show up immediately because the form re-renders on every change:

<:nested name="staff" title="Staff" />
<:field nested="staff" type="text" name="name" label="Name" required />

<:nested name="rooms" title="Rooms" />
<:field :let={field} nested="rooms" type="checkbox" name="teachers" label="Teachers">
  <%= for teacher <- DynamicForm.form_data(field)[:staff] || [] do %>
    <label>
      <input type="checkbox" name={"#{field.name}[]"} value={teacher[:name]} />
      {teacher[:name]}
    </label>
  <% end %>
</:field>

Values the user hasn't entered are absent rather than nil — default them when reading. A body's own entry is still reached through the form it receives (field.form[:label].value), which stays scoped to that entry.

Two things to know when a stored value must survive edits to what it points at: a hand-rendered checkbox group needs the hidden empty input the built-in group emits (<input type="hidden" name={"#{field.name}[]"} value="" />) so unchecking everything still clears the field, and entries have no stable identity — using a renameable field as the value means a rename orphans the selections that reference it.

Data mode

Definitions can be SurveyJS-compatible JSON, maps, or Instance structs. A JSON string passes straight in via the json attribute:

<DynamicForm.form id="contact-form" json={@json} />

Or decode at the edge (e.g. in mount/3) to work with the definition programmatically, and pass the instance to the same component:

instance = DynamicForm.Instance.decode!(~S({
  "title": "Contact Form",
  "elements": [
    {
      "type": "text",
      "name": "email",
      "inputType": "email",
      "title": "Email Address",
      "isRequired": true,
      "validators": [{"type": "email"}]
    },
    {
      "type": "comment",
      "name": "details",
      "title": "Details",
      "visibleIf": "{subject} = 'support'"
    }
  ]
}))
<DynamicForm.form id="contact-form" instance={@form_instance} />

Instances encode back to JSON with Jason.encode!/1, so definitions can be stored in a database, served over an API, or cached.

See the SurveyJS compatibility guide for exactly which SurveyJS features are supported, which are not (unsupported types render as a visible fallback box), and DynamicForm's extensions to the format.

Nested forms

A nested form is a repeating child form — a list of sub-records the user adds and removes, like a contact with multiple addresses. The submitted value is a nested list of maps, and every entry is validated with its own changeset:

%{name: "Ada", addresses: [%{street: "110 Main St", city: "Portland"}, ...]}

Nested forms work in both modes — the SurveyJS paneldynamic question type in data mode, and <:nested> slot declarations in declarative mode:

<:nested name="addresses" title="Addresses" min_entries={1} add_text="Add another address" />
<:field nested="addresses" type="text" name="street" label="Street" required />
<:field nested="addresses" type="text" name="city" label="City" required />

See the Nested Forms guide for the full feature: the scope model (nested + group combine), per-scope naming, entry seeding, per-entry validation and key uniqueness, custom controls per entry, and standalone-renderer events.

Rendering

DynamicForm.form/1

The unified entry point. Requires exactly one of the instance attribute (data mode) or <:field> slots (declarative mode). Common options:

<DynamicForm.form
  id="profile-form"
  instance={@form_instance}
  data={%{"name" => "Jane"}}
  form_name="profile"
  submit_text="Save Profile"
  validation_summary="detailed"
/>

Internally it wraps DynamicForm.RendererLive, a LiveComponent that manages the changeset, validation on change, and submission. Using the LiveComponent directly with <.live_component> is equivalent.

Edit mode

Pre-populate a form by passing data. Fields marked read_only (readOnly in data mode) display their values but can't be edited, and still submit them: text inputs render readonly, and controls HTML has no readonly for (selects, checkboxes, radios) render disabled alongside a hidden input carrying the value. That holds inside nested entries too, where the initial data can't be merged back in per entry:

<DynamicForm.form
  id="user-profile"
  instance={@form_instance}
  data={%{"id" => .id, "name" => .name, "email" => .email}}
/>

Extra keys in data that have no matching question (like id above) are preserved through submission the same way.

Messages

The component messages the parent LiveView as {:dynamic_form, event, %DynamicForm.Payload{}}. By default the only event is :success, a valid submission — this is where the application performs the side effect (insert a record, send an email, navigate):

def handle_info({:dynamic_form, :success, %DynamicForm.Payload{data: data}}, socket) do
  {:ok, contact} = MyApp.Contacts.create_contact(data)
  {:noreply, put_flash(socket, :info, "Created contact #{contact.id}")}
end

The payload carries the form's id (for matching when a page renders several forms), the changeset, the applied data, and an extra map that on_submit can write derived values into.

send_message_on adds the other two events — :change on every change and :submit on every submit, valid or not. Their payloads are routinely invalid, so check DynamicForm.Payload.valid?/1 before acting on them, and pair :change with change_debounce_in_ms to keep a message off every keystroke:

<DynamicForm.form id="signup" send_message_on={[:success, :change]} change_debounce_in_ms={300}>

To take over success handling, define on_success — a 1-arity function receiving the payload. It replaces the :success message: send a custom message, broadcast over PubSub, or do nothing to make the form fully self-contained:

<DynamicForm.form
  id="contact-form"
  instance={@form_instance}
  on_success={fn payload -> Phoenix.PubSub.broadcast(MyApp.PubSub, "contacts", payload.data) end}
/>

See the Lifecycle events guide for the full lifecycle and payload.

External submit buttons

Place the submit button anywhere on the page — modal footers, sticky bars — by hiding the built-in button and using the HTML form attribute. The form element's ID is "#{component_id}-form":

<DynamicForm.submit_button form="profile-form-form">
  Save Profile
</DynamicForm.submit_button>

<DynamicForm.form id="profile-form" instance={@form_instance} hide_submit />

Validation summary

Display errors at the top of the form in addition to inline errors: validation_summary="simple" shows a generic message, validation_summary="detailed" adds a list of each field error.

Render-only mode

For full control over the form lifecycle, add render_only and pass the parent-owned form: the component renders the definition's markup and emits phx-change/phx-submit with no phx-target, so events land in the parent LiveView's handle_event/3 — exactly like an idiomatic <form phx-change="validate" phx-submit="submit">. The definition drives presentation (inputs, labels, errors, conditional visibility); the parent's changeset drives the data:

<DynamicForm.form id="signup" render_only form={@form}>
  <:field type="text" name="name" label="Name" required />
  <:field type="text" name="email" input_type="email" label="Email" required />
</DynamicForm.form>
def handle_event("validate", %{"signup" => params}, socket) do
  changeset = Accounts.change_user(%User{}, params) |> Map.put(:action, :validate)
  {:noreply, assign(socket, form: to_form(changeset, as: "signup"))}
end

def handle_event("submit", %{"signup" => params}, socket) do
  # entirely yours
end

Override the event names with phx_change and phx_submit. The lifecycle attributes (on_change, change_debounce_in_ms, on_submit, on_success, send_message_on, data, form_name, validation_summary) have no meaning without the managed lifecycle and raise, and file upload questions require the stateful component.

The Render Only section of the /slot-forms demo page shows the complete pattern. DynamicForm.Renderer.render/1 is the underlying function component if you need to drive it directly.

Custom components

By default DynamicForm renders inputs, labels, and errors with its built-in components. Point the library at your own components module — typically the Phoenix-generated MyAppWeb.CoreComponents — globally:

config :dynamic_form, components: MyAppWeb.CoreComponents

or per form (the attribute wins over the config):

<DynamicForm.form id="contact-form" components={MyAppWeb.CoreComponents}>

Dispatch is per function with fallback: each component the renderer needs is looked up on your module, and anything it doesn't define renders through the built-ins. A stock Phoenix 1.8 CoreComponents works out of the box — its input/1 takes over text, email, number, textarea, select, and checkbox controls, button/1 takes over the submit button, and translate_error/1 routes error messages through your app's Gettext — while radio groups, checkbox groups, rating rows, panels, and the label/error pair around custom-control slot bodies fall back to the built-ins unless your module defines them (input_radio_group/1, input_checkbox_group/1, section/1, label/1, error/1).

See DynamicForm.Components for the full contract and the assigns each function receives, and the Styling guide for the complete styling story — including writing partial modules and per-field overrides. A module that can't be loaded raises — a typo fails loudly rather than silently rendering built-in styling. The Custom Components section of the /slot-forms demo page shows delegation and fallback side by side in one form.

Custom field types

Applications can extend the built-in question types with their own — app vocabulary like a multiselect or a select_with_search that shouldn't be baked into the library. A custom field type is two things: a registry entry declaring what the field casts as, and an input/1 clause in the components module declaring how it renders.

Register types globally, or per form with the custom_field_types attribute (per-form entries merge over — and win against — the config):

config :dynamic_form,
  custom_field_types: %{
    "multiselect" => {:array, :string},
    "select_with_search" => :string
  }
<DynamicForm.form id="signup" components={MyAppWeb.CoreComponents}>
  <:field type="select_with_search" name="school" label="School" options={@schools} required />
  <:field type="multiselect" name="days" label="Days" options={@days} />
</DynamicForm.form>

Rendering dispatches to the components module's input/1 with the usual assigns (field, type, label, options, placeholder, disabled) — define a matching clause:

def input(%{type: "select_with_search"} = assigns) do
  ~H"""
  <div class="fieldset mb-2" id={"#{@id}-combobox"} phx-hook="Combobox" phx-update="ignore">
    <span class="label mb-1">{@label}</span>
    <select name={@name} id={@id} class="w-full select">
      {Phoenix.HTML.Form.options_for_select(@options, @value)}
    </select>
  </div>
  """
end

The registered Ecto type drives the changeset: the field casts as declared, and {:array, _} types get the same hidden-input normalization as the built-in checkbox groups, so required works. visible_if/required_if and the validators attribute apply as with any question; validation beyond that is the application's job via on_change/on_submit.

Custom types work identically from data mode — {"type": "multiselect", "name": "days", "choices": ["mon", "tue"]} — since the type name is just data. Two failure modes to know: a question type that is neither built-in nor registered renders nothing (obvious in testing, not broken-looking in production), while a registered type without a matching input/1 clause falls to the module's catch-all and renders as a plain text input — a graceful degradation that still round-trips the value.

Lifecycle callbacks

Three optional hooks let the application participate in the form lifecycle: on_change extends validation live as the user types (add change_debounce_in_ms to run it after a pause instead of on every keystroke), on_submit batches expensive submit-only checks (each receives a DynamicForm.Payload and returns it), and on_success replaces the :success message for forms that complete some other way.

They are documented in one place — the Lifecycle events guide — with the signatures summarized in the Reference.

File uploads

type="file" questions upload directly to cloud storage using presigned URLs — files never pass through your server. Configuration lives in the question's metadata:

<:field type="file" name="documents" label="Documents" metadata={%{
  "max_entries" => 3,
  "max_file_size" => 10_000_000,
  "accept" => ~w(.pdf .png .jpg),
  "bucket" => "my-uploads",
  "object_name_prefix" => "forms/",
  "presigner" => %{"module" => "MyApp.UploadPresigner", "function" => "sign"}
}} />

The presigner receives the client filename and a context map and returns the presigned URL:

defmodule MyApp.UploadPresigner do
  def sign(filename, %{bucket: bucket, prefix: prefix, field_name: _name}) do
    # Generate a presigned PUT URL for "#{prefix}#{filename}" in bucket
  end
end

On the client, register an uploader for the external upload in assets/js/app.js (the demo app uses a stub that simulates success; a real implementation PUTs the file to entry.meta.url):

const GoogleStorage = (entries, onViewError) => {
  entries.forEach(entry => {
    // PUT the file to entry.meta.url, calling entry.progress(percent)
  })
}

const liveSocket = new LiveSocket("/live", Socket, {
  uploaders: {GoogleStorage},
  // ...
})

Completed uploads are stored in the form data as a list of maps with filename, cloud_bucket, cloud_path, cloud_provider, and uploaded_on. Deleting a file removes it from the form state only — deleting the stored object is left to the application.

Internationalization

Validation error messages translate through Gettext. Pass your app's Gettext backend to use your own translations; otherwise the library's default backend is used:

<DynamicForm.form id="contact-form" instance={@form_instance} gettext={MyAppWeb.Gettext} />