Standalone field layout primitives for composing form fields without Phoenix.HTML.FormField.
While PhiaUi.Components.Form (form_field/1, form_label/1, etc.) requires an
Ecto changeset and a Phoenix.HTML.FormField struct, the primitives in this module
accept plain strings and booleans. This makes them ideal wrappers for custom
inputs — checkboxes, radio groups, switches, sliders, and other non-standard
form controls — without needing a changeset.
Components
| Function | Purpose |
|---|---|
field/1 | Wrapper <div> with space-y-2 vertical rhythm |
field_label/1 | Styled <label> with optional required asterisk (*) |
field_description/1 | Helper/hint text in text-muted-foreground |
field_message/1 | Conditional error message in text-destructive, renders nothing when nil |
When to use
Use field/1 and its sub-components when:
- You are building a custom input that does not bind directly to a Phoenix form
- You want consistent field layout (label → input → description → error) without changesets
- You are composing standalone UI (e.g. wizard steps, modal forms without Ecto)
- You want to display a manually-constructed error string rather than a changeset error
For Ecto changeset forms, prefer PhiaUi.Components.Form.form_field/1 instead,
which reads errors directly from field.errors.
Basic composition
<.field>
<.field_label for="agree" required={true}>Accept Terms</.field_label>
<input id="agree" type="checkbox" />
<.field_description>You must accept the terms to continue.</.field_description>
<.field_message error={@terms_error} />
</.field>Wrapping custom components
The field/1 wrapper works with any PhiaUI component:
<%!-- Wrapping a standalone radio group --%>
<.field>
<.field_label>Notification frequency</.field_label>
<.radio_group :let={g} value={@frequency} name="frequency" phx-change="set_frequency">
<.radio_group_item value="realtime" label="Real-time" {g} />
<.radio_group_item value="daily" label="Daily digest" {g} />
<.radio_group_item value="never" label="Never" {g} />
</.radio_group>
<.field_description>How often would you like to receive email notifications?</.field_description>
<.field_message error={@frequency_error} />
</.field>
<%!-- Wrapping a slider --%>
<.field>
<.field_label for="volume" required={true}>Volume</.field_label>
<.slider id="volume" name="volume" value={@volume} phx-change="set_volume" />
<.field_message error={@volume_error} />
</.field>Multi-field settings panel
<.field>
<.field_label>Profile visibility</.field_label>
<.field_description>Controls who can see your public profile page.</.field_description>
<.toggle_group type="single" value={@visibility} phx-change="set_visibility">
<.toggle_group_item value="public">Public</.toggle_group_item>
<.toggle_group_item value="friends">Friends only</.toggle_group_item>
<.toggle_group_item value="private">Private</.toggle_group_item>
</.toggle_group>
<.field_message error={@visibility_error} />
</.field>Error handling pattern
field_message/1 renders nothing when :error is nil, so it is safe to always
render it and conditionally populate the error from your LiveView assigns:
# In your LiveView:
validate_form(attrs) ->
errors = %{email: "must be a valid email address"}
assign(socket, email_error: errors[:email]) # nil when valid
# In your template — always renders, only shows when non-nil:
<.field_message error={@email_error} />Dark mode
All sub-components use semantic Tailwind tokens (text-muted-foreground,
text-destructive) that automatically adapt to dark mode via PhiaUI's
@custom-variant dark configuration.
Summary
Functions
Renders a field wrapper <div> with space-y-2 vertical rhythm.
Renders helper text below a form field label.
Renders a styled form field label.
Renders a field error message when :error is not nil.
Functions
Renders a field wrapper <div> with space-y-2 vertical rhythm.
This is the outermost container for a field layout. It provides consistent
vertical spacing (space-y-2) between the label, input, description, and
error message sub-components.
Examples
<%!-- Minimal wrapper --%>
<.field>
<.field_label for="email">Email</.field_label>
<input id="email" type="email" class="..." />
</.field>
<%!-- Full field with all sub-components --%>
<.field>
<.field_label for="email" required={true}>Email</.field_label>
<input id="email" type="email" class="..." />
<.field_description>We'll never share your email.</.field_description>
<.field_message error={@email_error} />
</.field>
<%!-- Custom spacing override --%>
<.field class="mt-6">
<.field_label for="bio">Bio</.field_label>
<textarea id="bio" class="..." />
</.field>Attributes
class(:string) - Additional CSS classes merged into the wrapper<div>viacn/1. Use to adjust spacing or layout, e.g.class="mt-4"orclass="col-span-2".Defaults to
nil.Global attributes are accepted. Any HTML attributes forwarded to the wrapper
<div>(e.g.id,data-*).
Slots
inner_block(required) - The field's sub-components in order: label → input → description → message. All four are optional — only include what the field needs.
Renders helper text below a form field label.
Displayed in text-muted-foreground to visually distinguish it from the label
(which uses full foreground colour) and from error messages (which use destructive
colour). Use it for format hints, character limits, privacy notes, or constraints.
Examples
<%!-- Format hint --%>
<.field_description>Date format: YYYY-MM-DD</.field_description>
<%!-- Privacy note --%>
<.field_description>We'll never share your email with anyone else.</.field_description>
<%!-- Constraint --%>
<.field_description>Must be at least 8 characters, including a number.</.field_description>
<%!-- With a link --%>
<.field_description>
By continuing you agree to our <a href="/terms" class="underline">Terms of Service</a>.
</.field_description>Attributes
class(:string) - Additional CSS classes merged into the description<p>element viacn/1. Defaults tonil.- Global attributes are accepted. Any HTML attributes forwarded to the
<p>element.
Slots
inner_block(required) - The description text. Can include links or inline formatting. Keep it concise — ideally one sentence that helps the user understand what to enter.
Renders a styled form field label.
Applies text-sm font-medium leading-none with a disabled-state variant
(peer-disabled:cursor-not-allowed peer-disabled:opacity-70) for when the
associated input is disabled.
When :required is true, appends a red asterisk with aria-hidden="true"
so screen readers do not read it redundantly. The required constraint should
also be set on the input element itself (required attr) for browser validation.
Examples
<%!-- Basic label --%>
<.field_label for="email">Email address</.field_label>
<%!-- Required field indicator --%>
<.field_label for="password" required={true}>Password</.field_label>
<%!-- Without `for` (implicit association via wrapping) --%>
<label>
<.field_label>Username</.field_label>
<input type="text" />
</label>
<%!-- With extra styling --%>
<.field_label for="name" class="text-base">Full name</.field_label>Attributes
for(:string) - Theidof the associated form element. When set, the browser focuses the input when the label is clicked. Omit only when wrapping the input in the label directly (implicit association).Defaults to
nil.required(:boolean) - Whentrue, appends a red asterisk (*) after the label text witharia-hidden="true". The asterisk is hidden from screen readers because the required constraint should be communicated viarequiredon the input itself, not by reading "asterisk" aloud.Defaults to
false.class(:string) - Additional CSS classes merged into the<label>element viacn/1. Defaults tonil.Global attributes are accepted. Any HTML attributes forwarded to the
<label>element.
Slots
inner_block(required) - The label text. Can include inline elements like<abbr>for tooltips.
Renders a field error message when :error is not nil.
Displays nothing (no DOM element) when error is nil, so it is safe to
always include this component and conditionally populate :error from your
LiveView assigns. This avoids if expressions in the template.
The error <p> renders in text-sm font-medium text-destructive. For
accessibility, consider adding an id attribute and referencing it from
the input via aria-describedby:
<.field_message id="email-error" error={@email_error} />
<input aria-describedby="email-error" ... />Examples
<%!-- Always rendered, shows only when error is non-nil --%>
<.field_message error={@form_error} />
<%!-- With id for aria-describedby association --%>
<.field_message id="password-error" error={@password_error} />
<%!-- Explicit nil — renders nothing --%>
<.field_message error={nil} />
<%!-- Custom class --%>
<.field_message error={@error} class="mt-2 text-xs" />Attributes
error(:any) - Error message string to display, ornilto render nothing. When non-nil, renders a<p>intext-destructive. Safe to always render with a potentially nil value — no wrapperifexpression needed in the template.Defaults to
nil.class(:string) - Additional CSS classes merged into the error<p>element viacn/1. Defaults tonil.Global attributes are accepted. Any HTML attributes forwarded to the
<p>element (e.g.idforaria-describedby).