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
(label → title, options → choices, visible_if → visibleIf, ...).
See the Reference for every attribute.
Suppressing a label
Omitting label falls back to the capitalized field name, so a field is never
silently unlabelled. To render no label, set it blank — nil, false, or
"" all work, so a template can decide without special-casing:
<:field type="text" name="street" label={@compact && gettext("Street")} required />A blank label takes the required marker with it: an asterisk with nothing to
sit beside is noise, so required still validates but renders no *. The same
goes for <:group title={...}> and <:nested title={...}>.
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.
Group layout
type picks how a group arranges its members, the same way <:field type=>
picks a control. Two ship with the library:
<:group name="age_range" title="Age range" />
<:field group="age_range" type="text" name="min" label="From" />
<:field group="age_range" type="text" name="max" label="To" />"horizontal" is the default: members share a row and wrap when they run out
of width. type="vertical" stacks them instead.
Members are sized by their content rather than split into equal columns, so a
<:field> slot body can ask for an exact width and the row honors it:
<:group name="address" title="Address" />
<:field group="address" :let={field} type="text" name="street" label="Street">
<div class="w-96">
<input type="text" name={field.name} id={field.id} value={field.value} class="w-full input" />
</div>
</:field>Use exact widths (w-96, w-64), not fractions — a percentage width against
a content-sized parent resolves as auto, so w-1/2 renders at the input's
natural width.
To add your own layout, define dynamic_form_group/1 in your components
module and use its name — see
Styling: custom group types.
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.
Anything the browser doesn't submit — a section hidden by visible_if, a
question disabled by enable_if — keeps the value the form was already
holding, so edits made before it was hidden survive, as do its entries' ids.
Extra keys in data with no matching question, like an id, are merged
back in from the data you passed. Passing different data isn't a merge
at all: it rebuilds the form, so the new record's values win.
One limit: this works section by section. A question hidden inside a repeating entry, while the rest of that entry submits normally, still loses its value.
<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}")}
endThe 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
endOverride 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.CoreComponentsor 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, groups, 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,
dynamic_form_group/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>
"""
endThe 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
endOn 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} />