Corex form components accept field={@form[:name]} from Phoenix.Component.to_form/2. Set the form id in to_form/2 and wrap fields in <.form for={@form}>.

The examples below use Corex.Checkbox and Corex.Select. The same patterns apply to other form components; see each module's Form section in Hexdocs.

Example schema

defmodule MyApp.Form.Preferences do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :terms, :boolean, default: false
    field :country, Ecto.Enum, values: [:fra, :deu, :bel]
  end

  def changeset(preferences, attrs \\ %{}) do
    preferences
    |> cast(attrs, [:terms, :country])
    |> validate_required([:terms, :country])
    |> validate_acceptance(:terms)
  end

  def changeset_validate(preferences, attrs \\ %{}) do
    preferences
    |> cast(attrs, [:terms, :country])
    |> validate_required([:terms, :country], message: "can't be blank")
    |> validate_acceptance(:terms, message: "must be accepted to continue")
  end
end

Controller

Build the form from a changeset, render with <.form>, and post to a controller action.

def preferences_page(conn, _params) do
  form =
    %MyApp.Form.Preferences{}
    |> MyApp.Form.Preferences.changeset(%{})
    |> Phoenix.Component.to_form(as: :preferences, id: "preferences-form")

  render(conn, :preferences, form: form)
end

def preferences_create(conn, %{"preferences" => params}) do
  case MyApp.Form.Preferences.changeset(%MyApp.Form.Preferences{}, params) do
    %Ecto.Changeset{valid?: true} = changeset ->
      data = Ecto.Changeset.apply_changes(changeset)
      conn
      |> put_flash(:info, "Saved: terms=#{data.terms}, country=#{data.country}")
      |> redirect(to: "/account")

    changeset ->
      changeset = Map.put(changeset, :action, :insert)

      form =
        Phoenix.Component.to_form(changeset, as: :preferences, id: "preferences-form")

      render(conn, :preferences, form: form)
  end
end
<.form :let={f} for={@form} action="/account/preferences" method="post">
  <.checkbox field={f[:terms]} class="checkbox">
    <:label>Accept terms</:label>
    <:error :let={msg}>
      <.heroicon name="hero-exclamation-circle" class="icon" />
      {msg}
    </:error>
  </.checkbox>

  <.select
    field={f[:country]}
    class="select"
    items={Corex.List.new([
      %{label: "France", value: "fra"},
      %{label: "Belgium", value: "bel"},
      %{label: "Germany", value: "deu"}
    ])}
  >
    <:label>Country</:label>
    <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
    <:error :let={msg}>
      <.heroicon name="hero-exclamation-circle" class="icon" />
      {msg}
    </:error>
  </.select>

  <.action type="submit" class="button button--accent">Submit</.action>
</.form>

LiveView

Use an Ecto changeset as the source of truth. Add phx-change so validation runs as the user edits. On <.select>, pass controlled when the form is driven by LiveView (see Corex.Select).

def mount(_params, _session, socket) do
  form =
    %MyApp.Form.Preferences{}
    |> MyApp.Form.Preferences.changeset_validate(%{})
    |> to_form(action: :validate, as: :preferences, id: "preferences-form")

  {:ok, assign(socket, :form, form)}
end

def handle_event("validate", %{"preferences" => params}, socket) do
  form =
    %MyApp.Form.Preferences{}
    |> MyApp.Form.Preferences.changeset_validate(params)
    |> to_form(action: :validate, as: :preferences, id: "preferences-form")

  {:noreply, assign(socket, :form, form)}
end

def handle_event("save", %{"preferences" => params}, socket) do
  case MyApp.Form.Preferences.changeset(%MyApp.Form.Preferences{}, params) do
    %Ecto.Changeset{valid?: true} = changeset ->
      data = Ecto.Changeset.apply_changes(changeset)
      {:noreply, put_flash(socket, :info, "Saved: country=#{data.country}")}

    changeset ->
      {:noreply,
       assign(socket, :form, to_form(changeset, action: :validate, as: :preferences, id: "preferences-form"))}
  end
end
<.form for={@form} id="preferences-form" phx-change="validate" phx-submit="save">
  <.checkbox
    field={@form[:terms]}
    class="checkbox"
    invalid={Corex.FormField.invalid?(@form[:terms])}
  >
    <:label>Accept terms</:label>
    <:error :let={msg}>
      <.heroicon name="hero-exclamation-circle" class="icon" />
      {msg}
    </:error>
  </.checkbox>

  <.select
    field={@form[:country]}
    class="select"
    controlled
    invalid={Corex.FormField.invalid?(@form[:country])}
    items={Corex.List.new([
      %{label: "France", value: "fra"},
      %{label: "Belgium", value: "bel"},
      %{label: "Germany", value: "deu"}
    ])}
  >
    <:label>Country</:label>
    <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
    <:error :let={msg}>
      <.heroicon name="hero-exclamation-circle" class="icon" />
      {msg}
    </:error>
  </.select>

  <.action type="submit" class="button button--accent">Save</.action>
</.form>

Native form (plain HTML)

Use name on the component when you are not using to_form/2. Checkbox values follow Phoenix's checkbox param convention.

<form action="/register" method="post">
  <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />

  <.checkbox name="user[accept_terms]" class="checkbox">
    <:label>Accept terms</:label>
  </.checkbox>

  <.select
    name="user[country]"
    class="select"
    items={Corex.List.new([
      %{label: "France", value: "fra"},
      %{label: "Belgium", value: "bel"},
      %{label: "Germany", value: "deu"}
    ])}
  >
    <:label>Country</:label>
    <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
  </.select>

  <.action type="submit" class="button button--accent">Submit</.action>
</form>
def register_create(conn, %{"user" => %{"accept_terms" => terms, "country" => country}}) do
  checked = Phoenix.HTML.Form.normalize_value("checkbox", terms)

  conn
  |> put_flash(:info, "Submitted: terms=#{inspect(checked)}, country=#{country}")
  |> redirect(to: "/register")
end

Ecto validation (strict messages)

Use a dedicated changeset_validate/2 with stricter messages for controller re-render or LiveView phx-change. The HEEx is the same as in Controller or LiveView; only the changeset function differs.

def preferences_validate_page(conn, _params) do
  changeset =
    MyApp.Form.Preferences.changeset_validate(%MyApp.Form.Preferences{}, %{})

  form =
    Phoenix.Component.to_form(changeset, as: :preferences_validate, id: "preferences-validate-form")

  render(conn, :preferences_validate, form: form)
end

def preferences_validate_create(conn, %{"preferences_validate" => params}) do
  case MyApp.Form.Preferences.changeset_validate(%MyApp.Form.Preferences{}, params) do
    %Ecto.Changeset{valid?: true} = changeset ->
      data = Ecto.Changeset.apply_changes(changeset)
      conn
      |> put_flash(:info, "Saved: country=#{data.country}")
      |> redirect(to: "/account")

    changeset ->
      form =
        Phoenix.Component.to_form(
          Map.put(changeset, :action, :insert),
          as: :preferences_validate,
          id: "preferences-validate-form"
        )

      render(conn, :preferences_validate, form: form)
  end
end

In LiveView, call changeset_validate/2 inside handle_event("validate", ...) the same way as in the LiveView section above.

Error messages and invalid styling

Corex.FormField wires id, name, form, and errors into components that accept field={...}. It does not set invalid from changeset errors automatically.

  • Messages render through the :error slot when the field has errors and was used (used_input?/1).
  • Alert borders (data-invalid) are opt-in: pass invalid when you want visible invalid styling.
<.checkbox field={@form[:terms]} class="checkbox" invalid={Corex.FormField.invalid?(@form[:terms])}>
  <:label>Accept terms</:label>
  <:error :let={msg}>
    <.heroicon name="hero-exclamation-circle" class="icon" />
    {msg}
  </:error>
</.checkbox>

<.select
  field={@form[:country]}
  class="select"
  controlled
  invalid={Corex.FormField.invalid?(@form[:country])}
  items={Corex.List.new([
    %{label: "France", value: "fra"},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])}
>
  <:label>Country</:label>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
  <:error :let={msg}>
    <.heroicon name="hero-exclamation-circle" class="icon" />
    {msg}
  </:error>
</.select>

Use Corex.FormField.invalid?/1 on LiveView forms with phx-change so borders appear after the user interacts with a field, not on the initial empty render. Static demos without a changeset can pass invalid directly on the component.

Custom error presentation

Keep invalid off the control if you only want a custom affordance (for example a tooltip) without data-invalid on the host.

<.select field={@form[:country]} class="select relative" controlled>
  <:label>Country</:label>
  <:error :let={msg} class="absolute top-0 end-0">
    <.tooltip class="tooltip tooltip--sm" positioning={%Corex.Positioning{placement: "top-end"}}>
      <:trigger>
        <.heroicon name="hero-exclamation-circle" class="icon text-ink-alert" />
      </:trigger>
      <:content>{msg}</:content>
    </.tooltip>
  </:error>
</.select>

The :error slot still receives translated messages from the changeset; only the presentation changes.

Component reference

Hover a Component link for the Hexdocs summary card. Form links jump to that module's Form section.

ComponentForm
Corex.AngleSliderForm
Corex.CheckboxForm
Corex.ColorPickerForm
Corex.ComboboxForm
Corex.DatePickerForm
Corex.EditableForm
Corex.FileUploadForm
Corex.FileUploadLiveForm with submit
Corex.NativeInputForm
Corex.NumberInputForm
Corex.PasswordInputForm
Corex.PinInputForm
Corex.RadioGroupForm
Corex.SelectForm
Corex.SignaturePadForm
Corex.SwitchForm
Corex.TagsInputForm