Form layout primitives. Integrate with Phoenix.HTML.Form and Ecto changesets. These are the structural building blocks — pair them with components from inputs.md for actual input elements.
Table of Contents
form
Thin wrapper around Phoenix.Component.form/1. Forwards for, phx-change, phx-submit, and all global HTML attributes to <form>.
<%!-- Basic Ecto-integrated form --%>
<.form for={@form} phx-change="validate" phx-submit="save">
<div class="space-y-4">
<.phia_input field={@form[:name]} label="Full name" />
<.phia_input field={@form[:email]} type="email" label="Email" phx-debounce="blur" />
</div>
<.button type="submit" class="mt-6">Save</.button>
</.form>
<%!-- Sign-up form --%>
<.form for={@form} phx-change="validate" phx-submit="register">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<.phia_input field={@form[:first_name]} label="First name" />
<.phia_input field={@form[:last_name]} label="Last name" />
</div>
<.phia_input field={@form[:email]} type="email" label="Email" />
<.phia_input field={@form[:password]} type="password" label="Password" />
<.form_checkbox field={@form[:terms]} label="I accept the Terms of Service" />
<.button type="submit" class="w-full">Create account</.button>
</div>
</.form>def handle_event("validate", %{"user" => params}, socket) do
form = %User{} |> User.changeset(params) |> to_form(action: :validate)
{:noreply, assign(socket, form: form)}
end
def handle_event("register", %{"user" => params}, socket) do
case Accounts.create_user(params) do
{:ok, _user} -> {:noreply, push_navigate(socket, to: ~p"/dashboard")}
{:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))}
end
endfield
Standalone space-y-2 layout wrapper for custom inputs without a Phoenix.HTML.FormField binding. Use when you have no Ecto changeset.
Sub-components: field_label/1, field_description/1, field_message/1
<%!-- Custom field layout --%>
<.field>
<.field_label for="username">Username</.field_label>
<input id="username" name="username" type="text" class="phia-input" />
<.field_description>3–20 characters. Letters, numbers, underscores.</.field_description>
<.field_message :if={@username_error} error={@username_error} />
</.field>
<%!-- Terms checkbox --%>
<.field>
<div class="flex items-center gap-2">
<.checkbox id="terms" name="terms" phx-click="toggle-terms" checked={@terms_checked} />
<.field_label for="terms">
I agree to the <a href="/terms" class="underline">Terms of Service</a>
</.field_label>
</div>
<.field_message :if={@terms_error} error={@terms_error} />
</.field>
<%!-- Password with strength hint --%>
<.field>
<.field_label for="pass" required>Password</.field_label>
<.password_input id="pass" name="password" value={@password} />
<.field_description>Use at least 8 characters, a mix of letters and numbers.</.field_description>
</.field>
<%!-- Inline label + input row --%>
<.field class="flex items-center justify-between">
<.field_label for="dark-mode">Dark mode</.field_label>
<.switch id="dark-mode" name="dark_mode" checked={@dark_mode} phx-click="toggle-dark-mode" />
</.field>form_field
Phoenix.HTML.FormField-aware layout. Reads errors from the changeset automatically.
Sub-components: form_label/1, form_message/1
<%!-- Basic usage (rarely needed directly — phia_input wraps this) --%>
<.form for={@form} phx-submit="save">
<.form_field field={@form[:email]}>
<.form_label>Email</.form_label>
<input type="email" name={@form[:email].name} value={@form[:email].value} class="phia-input" />
<.form_message field={@form[:email]} />
</.form_field>
</.form>
<%!-- Custom compound field --%>
<.form_field field={@form[:price]}>
<.form_label>Price</.form_label>
<.input_addon>
<:prefix>$</:prefix>
<input type="number" name={@form[:price].name} value={@form[:price].value} class="phia-input" />
<:suffix>USD</:suffix>
</.input_addon>
<.form_message field={@form[:price]} />
</.form_field>label
Accessible <label> element with for binding and optional required indicator.
<.label for="email">Email address</.label>
<.label for="name" required>Full name</.label>
<.label for="bio" class="text-sm font-normal text-muted-foreground">Bio (optional)</.label>Error Translation
PhiaUI form components translate Ecto changeset errors using the standard Phoenix translate_error/1 helper. Custom error messages are passed as message in the validation tuple:
# In your schema
validates :email,
format: [with: ~r/@/, message: "must be a valid email address"],
length: [max: 160, message: "is too long (max 160 chars)"]
# With custom translation
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
endRecipes
Settings page pattern
<.card>
<.card_header>
<.card_title>Profile</.card_title>
<.card_description>Update your public profile information.</.card_description>
</.card_header>
<.card_content>
<.form for={@form} phx-change="validate" phx-submit="save-profile">
<div class="space-y-6">
<div class="flex items-center gap-6">
<.avatar size="xl">
<.avatar_image src={@current_user.avatar_url} />
<.avatar_fallback name={@current_user.name} />
</.avatar>
<.image_upload upload={@uploads.avatar} label="Change avatar" />
</div>
<.separator />
<div class="grid grid-cols-2 gap-4">
<.phia_input field={@form[:first_name]} label="First name" />
<.phia_input field={@form[:last_name]} label="Last name" />
</div>
<.phia_input field={@form[:username]} label="Username"
description="Your unique identifier on the platform." />
<.phia_input field={@form[:website]} label="Website" placeholder="https://" />
<.field>
<.field_label for="bio-field">Bio</.field_label>
<.textarea id="bio-field" name="user[bio]" rows={3}
placeholder="Tell us about yourself…" value={@form[:bio].value} />
</.field>
</div>
<.card_footer class="mt-6 px-0">
<.button type="submit">Save changes</.button>
<.button variant="ghost" type="button" phx-click="cancel">Cancel</.button>
</.card_footer>
</.form>
</.card_content>
</.card>Multi-step form
<%!-- Step tracker showing progress --%>
<.step_tracker class="mb-8">
<.step step={1} label="Account" status={step_status(@step, 1)} />
<.step step={2} label="Profile" status={step_status(@step, 2)} />
<.step step={3} label="Billing" status={step_status(@step, 3)} />
<.step step={4} label="Confirm" status={step_status(@step, 4)} />
</.step_tracker>
<%!-- Step 1 --%>
<div :if={@step == 1}>
<.form for={@form} phx-submit="next-step">
<.phia_input field={@form[:email]} type="email" label="Email" />
<.phia_input field={@form[:password]} type="password" label="Password" />
<.button type="submit" class="mt-4 w-full">Continue</.button>
</.form>
</div>
<%!-- Step 2 --%>
<div :if={@step == 2}>
<.form for={@form} phx-submit="next-step">
<div class="space-y-4">
<.phia_input field={@form[:name]} label="Full name" />
<.form_select field={@form[:timezone]} label="Timezone" options={timezone_options()} />
</div>
<div class="flex gap-2 mt-4">
<.button variant="outline" type="button" phx-click="prev-step">Back</.button>
<.button type="submit" class="flex-1">Continue</.button>
</div>
</.form>
</div>