How DynamicForm's markup gets its look, and every level of customizing it — from swapping in your app's own components to taking over a single control.
DynamicForm owns the markup for everything it renders: inputs, labels, errors, panels, and the submit button. Styling happens at four levels, from broadest to narrowest:
- Default styling — the built-in components, styled like a freshly generated Phoenix 1.8 app.
- A custom components module — point the library at your app's
CoreComponents(or design system) and every form renders through it. - Per-field custom markup — slot bodies take over a single control or block while the library keeps the rest.
- Render-only mode — the escape hatch when your LiveView should own the whole form loop.
Default styling
The built-in components (DynamicForm.CoreComponents) use the same markup
phx.new 1.8 generates: Tailwind utility classes plus
daisyUI component classes (input, select,
checkbox, radio, btn, fieldset, ...).
Phoenix 1.8+ apps vendor daisyUI by default, so the only setup is pointing
Tailwind at DynamicForm's source in assets/css/app.css:
@source "../../deps/dynamic_form/lib";Apps without daisyUI (Phoenix ≤ 1.7, or apps that removed it) can either
vendor daisyUI the way phx.new 1.8 does — see the comments in a freshly
generated assets/css/app.css — or skip the built-in styling entirely with
a custom components module, below.
Custom components module
The primary styling mechanism: point the library at your own components module and forms render through it instead of the built-ins. Globally:
config :dynamic_form, components: MyAppWeb.CoreComponentsor per form (the attribute wins over the config):
<DynamicForm.form id="contact-form" components={MyAppWeb.CoreComponents}>A stock Phoenix-generated CoreComponents works out of the box — restyle
core_components.ex and every dynamic form follows your app.
Per-function dispatch and fallback
Your module only needs to define the functions it wants to own. Each
component the renderer needs is looked up with function_exported?/3 and
falls back to the built-in when missing:
| Function | Renders | In Phoenix-generated CoreComponents? |
|---|---|---|
input/1 | text/email/number, textarea, select, checkbox controls | yes — works out of the box |
input_radio_group/1 | radiogroup and rating questions | no — built-in fallback |
input_checkbox_group/1 | multi-select checkbox groups | no — built-in fallback |
label/1, error/1 | around custom-control slot bodies | no — built-in fallback |
section/1 | panels | no — built-in fallback |
nested_entry/1 | the container around each repeating nested-form entry | no — built-in fallback |
button/1 | the submit button | yes — delegates |
translate_error/1 | error messages via the app's Gettext | yes — delegates |
Dispatch is deliberately per named function rather than "send everything to
input/1": a Phoenix-generated input/1 ends in a catch-all clause, so an
unknown type like "radio-group" would silently render a broken
<input type="radio-group">. The named contract keeps every control either
correctly delegated or correctly built-in.
A module that can't be loaded raises — a typo fails loudly instead of silently rendering built-in styling.
Writing a partial module
Since fallback is per function, a components module can be a thin overlay. This one restyles only the error messages and the submit button and leaves every input to the built-ins:
defmodule MyAppWeb.FormComponents do
use Phoenix.Component
def error(assigns) do
~H"""
<p class="mt-1 text-xs font-medium text-red-700">{render_slot(@inner_block)}</p>
"""
end
def button(assigns) do
~H"""
<button type={@type} disabled={@disabled} class="btn btn-secondary btn-wide">
{render_slot(@inner_block)}
</button>
"""
end
endThe assigns each function receives match the built-in implementations in
DynamicForm.CoreComponents — input/1 gets field, type, label, and
per-type extras (options, prompt, multiple, rows, placeholder,
disabled); the group components get field, label, options, style,
disabled; button/1 gets type, disabled, and an inner_block slot.
See DynamicForm.Components for the full contract.
Per-field custom markup
For one special field rather than a whole design system, give the <:field>
a slot body. Three tiers, all declarative-mode only:
Custom controls — the body receives the Phoenix.HTML.FormField and
replaces just the input, while the library still renders the label and
errors, and the changeset still validates the field:
<:field :let={field} type="text" name="budget" input_type="number" label="Budget">
<input type="range" min="0" max="1000" step="50"
name={field.name} id={field.id} value={field.value || 0}
class="range range-primary" />
</:field>Whatever the control submits under field.name flows through validation
unchanged. This also composes with your own component library — call it at
the control level (pass name/id/value, not field) so it renders just
the styled input and the library keeps ownership of the label and errors:
<:field :let={field} type="dropdown" name="occupation" label="Occupation" options={@occupations}>
<MyAppWeb.DesignSystem.input
type="select"
name={field.name}
id={field.id}
value={field.value}
options={@occupations}
/>
</:field>Content blocks — arbitrary HTML positioned within the form, escaped HEEx that can read parent assigns:
<:field type="html" name="intro">
<div class="alert alert-info">Welcome back, {@current_user.name}</div>
</:field>Fully custom elements — the body receives the Phoenix form for markup that reads current values (display-only; not a validated field):
<:field :let={form} type="custom" name="summary">
<p class="text-sm">Total: {Phoenix.HTML.Form.input_value(form, :budget)}</p>
</:field>See Usage: Custom markup for the full slot-body contract.
Full control: render-only mode
When styling hooks aren't enough and your LiveView should own the whole
loop, render_only renders the definition against a parent-owned form and
sends the phx-change/phx-submit events to the parent — see
Usage: Render-only mode. The markup still comes
from the components module (custom or built-in); what changes is who owns
the state.