Components

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)

Sign in
Use any email with an @ and an 8+ character password

must have the @ sign and no spaces

should be at least 8 characters

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

Updated

MD3'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

$
USD

:chips (MD3 input chips inside the field)

Ada Lovelace
Grace Hopper

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

New

A 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

New

An 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

Up to 8 per table

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)

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

Size
Plan
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

primary 60
secondary 60
tertiary 60
error 60

Track modes, and a value indicator

track: centered 70
value_indicator 45
45
track: none 60
track: inverted 60

Marks

Discrete (marks) 40
Custom labeled marks 30
0°C 30°C 60°C 100°C

Range, size, disabled

Range slider 20 – 80
size: md 60
Disabled 30

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

LiveComponent

MD3'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

LiveComponent

MD3'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 New

A 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)

elixir
phoenix

Value: ["elixir", "phoenix"]

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

New

LiveView 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)

Drag files here

JPG, PNG or PDF, up to 8 MB each

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)}

z7ealth 2026