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:

  1. Default styling — the built-in components, styled like a freshly generated Phoenix 1.8 app.
  2. A custom components module — point the library at your app's CoreComponents (or design system) and every form renders through it.
  3. Per-field custom markup — slot bodies take over a single control or block while the library keeps the rest.
  4. 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.CoreComponents

or 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:

FunctionRendersIn Phoenix-generated CoreComponents?
input/1text/email/number, textarea, select, checkbox controlsyes — works out of the box
input_radio_group/1radiogroup and rating questionsno — built-in fallback
input_checkbox_group/1multi-select checkbox groupsno — built-in fallback
label/1, error/1around custom-control slot bodiesno — built-in fallback
section/1panelsno — built-in fallback
nested_entry/1the container around each repeating nested-form entryno — built-in fallback
button/1the submit buttonyes — delegates
translate_error/1error messages via the app's Gettextyes — 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
end

The assigns each function receives match the built-in implementations in DynamicForm.CoreComponentsinput/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.