Forms
PhoenixPaper.TextField, PasswordField, Select, NumberField, Checkbox, Switch, ThemeToggle, RadioGroup, Slider, DatePicker, TimePicker, Autocomplete and Upload. Every one of them takes a field from to_form/2, the same way a generated core_components.ex input does, so they drop straight into Phoenix's own <.form>.
Building a Form
There's no form component: MD3 doesn't define one, and Phoenix's own <.form> already does the job. Build the form from to_form/2 as usual and give each pp_* input its field; errors show once a field has been used (the same used_input? rule as a generated core_components input). Here, a sign-in card: a Card around a <.form> with a text field, a password field, a checkbox and a submit button.
Try it (an invalid submit shows the errors, a valid one a flash)
Show code
<.pp_card class="w-full max-w-sm">
<:title>Sign in</:title>
<.form for={@form} id="login-form" phx-change="validate" phx-submit="login" class="mt-4 flex flex-col gap-4">
<.pp_text_field field={@form[:email]} type="email" label="Email" autocomplete="username" />
<.pp_password_field field={@form[:password]} label="Password" autocomplete="current-password" />
<.pp_checkbox field={@form[:remember_me]} label="Keep me signed in" />
<div class="flex items-center justify-between gap-2">
<.pp_button variant="text" navigate={~p"/users/reset-password"}>Forgot password?</.pp_button>
<.pp_button type="submit" phx-disable-with="Signing in...">Sign in</.pp_button>
</div>
</.form>
</.pp_card>
# In the LiveView: an ordinary changeset-backed form
def mount(_params, _session, socket) do
{:ok, assign(socket, :form, to_form(Accounts.change_user_login(%User{})))}
end
def handle_event("validate", %{"user" => params}, socket) do
changeset = Accounts.change_user_login(%User{}, params)
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
Text Field
UpdatedMD3's text field, outlined or filled, with a pure-CSS floating label and no JavaScript. supporting_text sits under the field; errors switch it to the error color with a trailing error icon and wire aria-describedby. The :chips slot puts MD3 input chips inside the field, before the text (the row wraps and the label stays raised while there are chips), and :menu anchors content such as a listbox to the field box.
Variants
States
We'll never share your email.
Colors
Adornments
:chips (MD3 input chips inside the field)
Multiline
| Option | Type | Default | Description |
|---|---|---|---|
color
|
"primary" | "secondary" | "tertiary" | "error"
|
"primary"
|
|
disabled
|
boolean
|
false
|
|
errors
|
list
|
[]
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
multiline
|
boolean
|
false
|
renders a <textarea rows={@rows}> |
name
|
any
|
nil
|
|
paperize
|
boolean
|
true
|
|
rows
|
integer
|
3
|
multiline only |
supporting_text
|
string
|
nil
|
|
type
|
string
|
"text"
|
|
value
|
any
|
nil
|
|
variant
|
"outlined" | "filled"
|
"outlined"
|
| Slot | Attrs | Description |
|---|---|---|
:chips
|
|
MD3 input chips inside the field, before the input; keeps the label raised |
:end_adornment
|
|
|
:menu
|
|
content anchored to the field box, e.g. a listbox at top-full |
:start_adornment
|
|
Show code
<.pp_text_field variant="outlined" label="Outlined (default)" name="outlined" />
<.pp_text_field variant="filled" label="Filled" name="filled" />
<.pp_text_field label="With an error" name="error" value="not-an-email" errors={["is not a valid email"]} />
<.pp_text_field color="secondary" label="Secondary" name="color_secondary" />
<.pp_text_field label="Amount" name="amount" value="42.00">
<:start_adornment>$</:start_adornment>
<:end_adornment>USD</:end_adornment>
</.pp_text_field>
<.pp_text_field label="Search" name="q">
<:start_adornment><.pp_icon name="hero-magnifying-glass" size="sm" /></:start_adornment>
</.pp_text_field>
<.pp_text_field multiline rows={3} label="Bio" name="bio" />
<%!-- Input chips inside the field: pass :chips only when there are some --%>
<.pp_text_field id="to" name="q" label="To">
<:chips :if={@recipients != []}>
<.pp_chip
:for={r <- @recipients}
variant="input"
deletable
on_delete={JS.push("remove_recipient", value: %{name: r})}
>
{r}
</.pp_chip>
</:chips>
</.pp_text_field>
Password Field
NewA text field of type password with MD3's trailing visibility toggle: an icon-button toggle (eye / eye-slash, aria-pressed) that flips the input between password and text on the client, with no round trip. LiveView keeps the flipped type across patches, so a validation error doesn't hide the password again. Give it an id, name or field so the toggle can find its input.
Outlined, filled, with an error
At least 12 characters
doesn't match
| Option | Type | Default | Description |
|---|---|---|---|
disabled
|
boolean
|
false
|
|
errors
|
list
|
[]
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
name
|
any
|
nil
|
|
paperize
|
boolean
|
true
|
|
show_label
|
string
|
"Show password"
|
|
supporting_text
|
string
|
nil
|
|
value
|
any
|
nil
|
|
variant
|
"outlined" | "filled"
|
"outlined"
|
Show code
<.pp_password_field field={@form[:password]} label="Password" autocomplete="current-password" />
<.pp_password_field name="new_password" label="New password" variant="filled" supporting_text="At least 12 characters" />
Select
A native select element styled as MD3's outlined or filled text field, always with a floating label.
Variants
| Option | Type | Default | Description |
|---|---|---|---|
disabled
|
boolean
|
false
|
|
errors
|
list
|
[]
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
name
|
any
|
nil
|
|
options
|
list (required)
|
|
list of {label, value} tuples, or plain values |
paperize
|
boolean
|
true
|
|
prompt
|
string
|
nil
|
|
supporting_text
|
string
|
nil
|
|
value
|
any
|
nil
|
|
variant
|
"outlined" | "filled"
|
"outlined"
|
Show code
<.pp_select
label="Country"
name="country"
prompt="Choose one"
options={["Canada", "Mexico", "United States"]}
/>
<.pp_select
variant="filled"
label="Country"
name="country_filled"
prompt="Choose one"
options={["Canada", "Mexico", "United States"]}
/>
Number Field
NewAn MD3 text field of type number with two trailing icon-button steppers. min, max and step are passed to the input and the steppers respect them; decrease_label and increase_label name the steppers for screen readers.
Outlined and filled
| Option | Type | Default | Description |
|---|---|---|---|
decrease_label
|
string
|
"Decrease"
|
|
disabled
|
boolean
|
false
|
|
errors
|
list
|
[]
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
increase_label
|
string
|
"Increase"
|
|
label
|
string
|
nil
|
|
max
|
any
|
nil
|
|
min
|
any
|
nil
|
|
name
|
any
|
nil
|
|
paperize
|
boolean
|
true
|
|
step
|
any
|
1
|
|
supporting_text
|
string
|
nil
|
|
value
|
any
|
nil
|
|
variant
|
"outlined" | "filled"
|
"outlined"
|
Show code
<.pp_number_field field={@form[:quantity]} label="Quantity" min={0} max={10} />
<.pp_number_field label="Amount" name="amount" step={0.5} variant="filled" />
Checkbox
MD3 metrics with a 40dp state layer, plus the hidden-input trick so an unchecked box still submits false. indeterminate shows a dash (a parent of partially checked children); error switches to the error color.
States
| Option | Type | Default | Description |
|---|---|---|---|
checked
|
boolean
|
nil
|
|
disabled
|
boolean
|
false
|
|
error
|
boolean
|
false
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
indeterminate
|
boolean
|
false
|
|
label
|
string
|
nil
|
|
name
|
any
|
nil
|
|
paperize
|
boolean
|
true
|
|
ripple
|
boolean
|
false
|
the centered ripple on click, see PhoenixPaper.Ripple |
value
|
any
|
"true"
|
Show code
<.pp_checkbox label="Paperized (default)" checked={true} />
<.pp_checkbox paperize={false} label="paperize: false" />
Switch
An on/off toggle with MD3's track and handle: the handle grows when on and when pressed. icons adds MD3's check/close glyphs inside the handle.
States
| Option | Type | Default | Description |
|---|---|---|---|
checked
|
boolean
|
nil
|
|
disabled
|
boolean
|
false
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
icons
|
boolean
|
false
|
check/x icons in the handle |
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
name
|
any
|
nil
|
|
paperize
|
boolean
|
true
|
|
ripple
|
boolean
|
false
|
the centered ripple on click |
value
|
any
|
"true"
|
Show code
<.pp_switch label="Notifications" checked={true} name="notifications" />
Theme Toggle
Sets data-theme on <html>, the attribute daisyUI and Phoenix 1.8's app.css already key off. The default is a System / Light / Dark segmented control, like the one in Phoenix 1.8's generated layout, with System selected: it removes data-theme so the page follows the OS, live. Which option is selected is pure CSS (read from data-theme), so every toggle on the page agrees and a LiveView re-render can't reset it. The choice is saved in localStorage under phx:theme, the key a Phoenix 1.8 root layout restores on load.
Try it (changes this whole page's theme)
Both change the same data-theme, so they stay in sync with each other and with the theme picker in the top-right corner. Pick Light or Dark and reload: the choice is restored before first paint. Pick System to follow your OS again.
| Option | Type | Default | Description |
|---|---|---|---|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
visible text next to the control |
on_toggle
|
JS
|
%JS{}
|
extra JS commands run before the built-in data-theme flip |
paperize
|
boolean
|
true
|
|
ripple
|
boolean
|
true
|
the Material ripple effect on click/tap — off whenever paperize is false, see PhoenixPaper.Ripple |
target
|
string
|
"html"
|
CSS selector for the element to toggle data-theme on |
Show code
<%!-- System / Light / Dark, System selected by default --%>
<.pp_theme_toggle />
<%!-- With visible text next to it --%>
<.pp_theme_toggle label="Theme" />
<%!-- Also save the choice server-side: receives %{"theme" => "dark"} --%>
<.pp_theme_toggle on_toggle={JS.push("save_theme")} />
Radio Group
A labeled set of mutually exclusive radio buttons sharing one name, with MD3 metrics and a 40dp state layer; error switches to the error color.
Options, and error
| Option | Type | Default | Description |
|---|---|---|---|
disabled
|
boolean
|
false
|
|
error
|
boolean
|
false
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
name
|
any
|
nil
|
|
options
|
list (required)
|
|
list of {label, value} tuples, or plain values |
paperize
|
boolean
|
true
|
|
ripple
|
boolean
|
false
|
the Material ripple effect on click/tap (default: false) — see PhoenixPaper.Ripple |
value
|
any
|
nil
|
Show code
<.pp_radio_group
label="Size"
name="size"
value="md"
options={[{"Small", "sm"}, {"Medium", "md"}, {"Large", "lg"}]}
/>
Slider
A native range input re-skinned as MD3's slider: a thin handle between active and inactive track segments, with stop indicators at the ends. M3 Expressive adds track sizes xs–xl, a centered track (filled from the middle) and a value indicator above the handle.
Colors
Track modes, and a value indicator
Marks
Range, size, disabled
Vertical
| Option | Type | Default | Description |
|---|---|---|---|
color
|
"primary" | "secondary" | "tertiary" | "error"
|
"primary"
|
|
disabled
|
boolean
|
false
|
|
field
|
Phoenix.HTML.FormField
|
nil
|
|
id
|
any
|
nil
|
|
label
|
string
|
nil
|
|
marks
|
any
|
false
|
true (every step), a list of values, or a list of {value, label} tuples |
max
|
any
|
100
|
|
min
|
any
|
0
|
|
name
|
any
|
nil
|
|
orientation
|
"horizontal" | "vertical"
|
"horizontal"
|
|
paperize
|
boolean
|
true
|
|
size
|
"xs" | "sm" | "md" | "lg" | "xl"
|
"xs"
|
|
step
|
any
|
1
|
|
stop_indicator
|
boolean
|
true
|
|
track
|
"normal" | "inverted" | "centered" | "none"
|
"normal"
|
|
value
|
any
|
nil
|
a number, or a {low, high} tuple for a range slider |
value_indicator
|
boolean
|
false
|
Expressive value bubble while dragging |
Show code
<.pp_slider name="volume" label="Volume" value={60} />
<.pp_slider name="volume_md" label="Medium track" value={60} size="md" />
<.pp_slider name="volume_no_track" label="track: none" value={60} track="none" />
<%!-- discrete, evenly-spaced marks --%>
<.pp_slider name="volume_marks" label="Discrete (marks)" value={40} step={20} marks={true} />
<%!-- custom labeled marks --%>
<.pp_slider
name="temperature"
label="Temperature"
value={30}
max={100}
marks={[{0, "0°C"}, {50, "50°C"}, {100, "100°C"}]}
/>
<%!-- range slider: a {low, high} tuple instead of a single number --%>
<.pp_slider name="price" label="Price range" value={{20, 80}} />
<.pp_slider name="volume_vertical" orientation="vertical" value={60} />
<.pp_slider name="volume_disabled" label="Disabled" value={30} disabled />
Date Picker
LiveComponentMD3's date picker as a LiveComponent (a calendar keeps state: the month in view, the year grid, a pending choice). docked opens a calendar panel under an outlined field and commits on click; modal opens a dialog with a headline, the calendar, Cancel/OK, and an input mode (the pencil). min/max bound the dates; with field= it renders a hidden input in ISO format.
docked and modal
| Option | Description |
|---|---|
field / name / value
|
form integration; the value is a Date (or ISO string) |
label
|
the field's label |
variant
|
docked (default) | modal |
range
|
boolean: MD3's date range picker; the second pick ends the range (swapped if earlier), submitted as <name>_start / <name>_end |
min / max
|
the earliest / latest selectable Date |
first_day_of_week
|
1 (Monday) .. 7 (Sunday, default) |
format
|
a function Date -> text for the field (default: ISO) |
clearable
|
boolean: a Clear button |
month_names / weekday_names
|
for translation |
cancel_label / ok_label / clear_label / headline_label
|
button and headline texts, for translation |
range_headline_label / start_label / end_label
|
the range picker's texts, for translation |
supporting_text / errors / disabled
|
same as Text Field |
on_change
|
function called in the LiveView's process with the new Date, outside a form |
Show code
<.live_component
module={PhoenixPaper.DatePicker}
id="due-on"
field={@form[:due_on]}
label="Due date"
min={Date.utc_today()}
/>
<.live_component module={PhoenixPaper.DatePicker} id="starts-on" field={@form[:starts_on]} label="Start date" variant="modal" />
<%!-- A date range: submitted as stay_start / stay_end --%>
<.live_component module={PhoenixPaper.DatePicker} id="stay" name="stay" label="Stay" range />
Time Picker
LiveComponentMD3's time picker as a LiveComponent: the field opens a dialog with the hour/minute selector, a dial (pick the hour, then the minutes) or an input mode (the keyboard icon), and Cancel/OK; nothing changes until OK. hour_cycle={24} puts 13–00 on an inner ring. With the JS hook (Getting Started, step 4) you can also drag the hand to any hour or exact minute.
12-hour and 24-hour
| Option | Description |
|---|---|
field / name / value
|
form integration; the value is a Time (or HH:MM string) |
label
|
the field's label |
hour_cycle
|
12 (default, with an AM/PM selector) | 24 |
supporting_text / errors / disabled
|
same as Text Field |
headline_label / input_headline_label
|
the dialog headlines, for translation |
cancel_label / ok_label / am_label / pm_label
|
button texts, for translation |
on_change
|
function called in the LiveView's process with the new Time, outside a form |
Show code
<.live_component module={PhoenixPaper.TimePicker} id="starts-at" field={@form[:starts_at]} label="Start time" />
<.live_component module={PhoenixPaper.TimePicker} id="ends-at" field={@form[:ends_at]} label="End time" hour_cycle={24} />
Autocomplete
LiveComponent NewA text field that suggests options as you type, as a LiveComponent: an MD3 text field over the MD3 menu surface, filtering on the server (case- and accent-insensitive). Arrow keys move through the options, Enter picks, Escape closes. The value is submitted from a hidden input and each pick reaches the form's phx-change; outside a form, on_change is called with the new value.
Try it: type “ca” or “mé”
Accents don't matter
Value: nil
multiple: picks become input chips (Backspace removes the last)
| Option | Description |
|---|---|
options
|
plain values or {label, value} tuples (required) |
field / name / value
|
the selected option's value, submitted from a hidden input |
label, variant, supporting_text, errors, disabled
|
as for TextField |
multiple
|
pick several values as input chips; value is a list, submitted as name[] (plus an empty name[] so clearing every chip still submits) |
on_change
|
fn value -> ... end, run in the LiveView process (a list with multiple) |
no_results_label
|
shown when nothing matches (default "No results") |
Show code
<.form for={@form} id="address-form" phx-change="validate">
<.live_component
module={PhoenixPaper.Autocomplete}
id="country"
field={@form[:country]}
label="Country"
options={[{"Canada", "ca"}, {"México", "mx"}, {"United States", "us"}]}
/>
</.form>
<%!-- Outside a form: on_change runs in the LiveView process --%>
<.live_component
module={PhoenixPaper.Autocomplete}
id="country-filter"
name="country"
label="Country"
options={@countries}
on_change={fn value -> send(self(), {:country_picked, value}) end}
/>
<%!-- Several values as input chips; drop the "" sentinel when casting --%>
<.live_component
module={PhoenixPaper.Autocomplete}
id="tags"
field={@form[:tags]}
label="Tags"
options={@all_tags}
multiple
/>
Upload
NewLiveView uploads (live_file_input) as an MD3 drop zone: an outline-variant area with a tonal browse button that shows MD3's dragged state while files are over it, then one row per file with an image preview or document icon, a linear progress bar, its errors as text and a cancel button (on_cancel, phx-value-ref). It needs allow_upload/3 in mount and a form with phx-change, LiveView's own requirements.
Try it: up to 3 JPG, PNG or PDF files (nothing is stored)
| Option | Type | Default | Description |
|---|---|---|---|
browse_label
|
string
|
"Browse files"
|
|
cancel_label
|
string
|
"Cancel"
|
|
error_to_string
|
any
|
nil
|
a function from an upload error to text; defaults to English messages |
label
|
string
|
"Drag files here"
|
|
on_cancel
|
any
|
nil
|
event (or JS) for each file's cancel button; sends phx-value-ref |
paperize
|
boolean
|
true
|
|
preview
|
boolean
|
true
|
show image previews |
supporting_text
|
string
|
nil
|
a hint under the label, e.g. size limits |
target
|
any
|
nil
|
phx-target for on_cancel |
upload
|
UploadConfig (required)
|
|
the upload, e.g. @uploads.photos |
Show code
<.form for={@form} id="photos-form" phx-change="validate" phx-submit="save">
<.pp_upload upload={@uploads.photos} on_cancel="cancel_upload" supporting_text="JPG or PNG, up to 8 MB" />
<.pp_button type="submit">Upload</.pp_button>
</.form>
# In the LiveView
def mount(_params, _session, socket) do
{:ok, allow_upload(socket, :photos, accept: ~w(.jpg .png), max_entries: 3)}
end
def handle_event("cancel_upload", %{"ref" => ref}, socket),
do: {:noreply, cancel_upload(socket, :photos, ref)}