Number Input

Numeric field with stepper buttons, modifier-key step sizes, snapping, wheel scrubbing, and locale-aware formatting.

Basic

.heex
<div class="max-w-[160px] w-full">
  <.number_input name="quantity" label="Quantity" value={1} min={0} max={99} />
</div>

Sizes

Set size to one of xs, sm, md, lg, or xl. Both the field and the stepper buttons scale together.

.heex
<.number_input size="xs" name="n_xs" value={0} />
<.number_input size="sm" name="n_sm" value={0} />
<.number_input size="md" name="n_md" value={0} />
<.number_input size="lg" name="n_lg" value={0} />
<.number_input size="xl" name="n_xl" value={0} />

Label, description, and help text

Combine label, sublabel, description, and help_text for the full form-control surface.

(required)

Tell us how this product performed.

From 1 (worst) to 5 (best).
.heex
<.number_input
  name="rating"
  label="Rating"
  sublabel="(required)"
  description="Tell us how this product performed."
  help_text="From 1 (worst) to 5 (best)."
  value={3}
  min={1}
  max={5}
  step={1}
/>

Min, max, and step

min and max clamp the value on stepping. step sets the amount added or subtracted on each click or arrow press.

.heex
<.number_input name="step_quantity" label="Quantity" value={1} min={0} max={99} step={1} />
<.number_input name="step_amount" label="Amount" value={0.0} min={0} step={0.25} />

Modifier-augmented stepping

Hold Shift for large_step and Alt for small_step while pressing an arrow key or stepper button. Defaults are step * 10 and step / 10.

Shift for 1.0 steps, Alt for 0.01 steps.
.heex
<.number_input
  name="zoom"
  label="Zoom"
  help_text="Shift for 1.0 steps, Alt for 0.01 steps."
  value={1.0}
  min={0.1}
  max={5.0}
  step={0.1}
  small_step={0.01}
  large_step={1.0}
/>

Snap to step

With snap_on_step, increment and decrement round to the nearest multiple of step, even when the current value is off-grid.

Starts at 11. Press + to snap to 15, then 20, 25.
.heex
<.number_input
  name="grid_size"
  label="Grid size"
  help_text="Starts at 11. Press + to snap to 15, then 20, 25."
  value={11}
  step={5}
  snap_on_step
/>

Without stepper buttons

Set controls="none" to drop the flanking buttons. Keyboard stepping (arrow keys, PageUp, PageDown, Home, End) still works.

Type, or use the arrow keys to step.
.heex
<.number_input
  name="freeform"
  label="Value"
  help_text="Type, or use the arrow keys to step."
  value={0}
  controls="none"
/>

Wheel scrubbing

allow_wheel_scrub lets the user adjust the value by scrolling the mouse wheel while holding Alt on a focused field. The modifier prevents accidental changes from page scroll.

Focus the field, then Alt + scroll to adjust.
.heex
<.number_input
  name="opacity"
  label="Opacity"
  help_text="Focus the field, then Alt + scroll to adjust."
  value={0.5}
  min={0}
  max={1}
  step={0.05}
  allow_wheel_scrub
  format={%{maximum_fraction_digits: 2}}
/>

Currency

Pass a format map with style: "currency" and a currency code. The displayed value is formatted; the form receives the raw number.

.heex
<.number_input
  name="price"
  label="Price"
  value={1299.0}
  min={0}
  step={0.01}
  locale="en-US"
  format={%{style: "currency", currency: "USD", minimum_fraction_digits: 2}}
/>

Locale-aware formatting

The same value rendered for three locales. Grouping, decimal separator, and currency symbol all follow the locale.

.heex
<.number_input
  name="usd"
  label="US English"
  value={1_234_567.89}
  locale="en-US"
  format={%{style: "currency", currency: "USD"}}
/>
<.number_input
  name="eur"
  label="German"
  value={1_234_567.89}
  locale="de-DE"
  format={%{style: "currency", currency: "EUR"}}
/>
<.number_input
  name="brl"
  label="Brazilian Portuguese"
  value={1_234_567.89}
  locale="pt-BR"
  format={%{style: "currency", currency: "BRL"}}
/>

Percent and fraction digits

Use style: "percent" to format a 0..1 value as a percentage, and maximum_fraction_digits to control precision.

.heex
<.number_input
  name="progress"
  label="Progress"
  value={0.425}
  min={0}
  max={1}
  step={0.01}
  format={%{style: "percent", maximum_fraction_digits: 1}}
/>

Affixes

Four slots compose around the field: :inner_prefix and :inner_suffix sit inside the border and share the focus ring; :outer_prefix and :outer_suffix attach outside, between the field and the stepper buttons.

USD
min
.heex
<.number_input name="affix_amount" label="Amount" value={0.0} step={0.01}>
  <:inner_prefix class="pointer-events-none text-foreground-softer">$</:inner_prefix>
</.number_input>

<.number_input name="affix_weight" label="Weight" value={50}>
  <:inner_suffix class="pointer-events-none px-1 text-foreground-softer">kg</:inner_suffix>
</.number_input>

<.number_input name="affix_price" label="Unit price" value={99}>
  <:outer_prefix class="px-3 text-foreground-softer">USD</:outer_prefix>
</.number_input>

<.number_input name="affix_duration" label="Duration" value={30}>
  <:outer_suffix class="px-3 text-foreground-softer">min</:outer_suffix>
</.number_input>

Outer suffix with action

Drop a .button into :outer_suffix to join a trailing action to the field. Pair with controls="none" so the action sits flush with the field's edge.

.heex
<.number_input
  name="topup"
  label="Top up"
  value={25.0}
  min={1}
  step={1}
  controls="none"
  locale="en-US"
  format={%{style: "currency", currency: "USD", minimum_fraction_digits: 2}}
>
  <:outer_suffix>
    <.button color="primary">Add funds</.button>
  </:outer_suffix>
</.number_input>

Disabled

disabled blocks focus on the field and dims the stepper buttons so they cannot be pressed.

.heex
<.number_input name="locked" label="Quantity" value={42} disabled />

Readonly

readonly keeps the value selectable and the field focusable, but prevents edits. Stepper buttons remain visible but inert.

.heex
<.number_input name="reference" label="Year" value={2026} readonly />

Pricing calculator

Two number inputs feed a live monthly total. Each tick of the stepper hits the server through phx-change on the surrounding form.

Configure your plan

Pricing updates as you change the inputs.

$12/mo per seat.
GB
Steps through 100, 500, 2,000, and 10,000 GB tiers.
Monthly total $70
.heex
<form phx-change="recalc" class="flex flex-col gap-4">
  <.number_input
    name="seats"
    label="Seats"
    help_text={"$#{@per_seat}/mo per seat."}
    value={@seats}
    min={1}
    max={500}
    step={1}
    large_step={10}
  />

  <.number_input
    name="storage"
    label="Storage"
    value={@storage}
    min={100}
    max={10_000}
    step={100}
    snap_on_step
  >
    <:outer_suffix class="px-3 text-foreground-softer">GB</:outer_suffix>
  </.number_input>
</form>

<div class="flex items-baseline justify-between rounded-lg bg-sunken/40 px-4 py-3">
  <span class="text-sm text-foreground-softer">Monthly total</span>
  <span class="text-xl font-semibold tabular-nums">${@total}</span>
</div>
.ex
def handle_event("recalc", %{"seats" => _seats, "storage" => _storage}, socket) do
  # Parse the inputs, clamp to bounds, recompute the total
  {:noreply, socket}
end

Cart line item

A compact size="sm" quantity stepper bound to a changeset. Line total recomputes server-side as the quantity changes.

Walnut keyboard tray

SKU WK-104 · $24.00 each

Line total

$48.00

.heex
<.form :let={f} for={@form} phx-change="update" class="flex items-center gap-4">
  <img src={@product.image} class="size-14 rounded-md" alt="" />

  <div class="flex-1">
    <p class="text-sm font-medium">{@product.name}</p>
    <p class="text-xs text-foreground-softer">
      ${@unit_price} each
    </p>

    <div class="mt-2 w-28">
      <.number_input field={f[:quantity]} size="xs" min={1} max={50} />
    </div>
  </div>

  <p class="text-base font-semibold tabular-nums">
    ${@quantity * @unit_price}
  </p>
</.form>
.ex
def handle_event("update", %{"line" => params}, socket) do
  # Validate, update the form, and recompute the line total
  {:noreply, socket}
end

Range filter

Two number inputs collaborating in a filter card. The matching count refreshes whenever either bound changes.

Price

15 of 24 products match
.heex
<form phx-change="filter" class="grid grid-cols-2 gap-3">
  <.number_input
    name="min_price"
    label="Min"
    size="sm"
    value={@min_price}
    min={0}
    max={5000}
    step={50}
    large_step={500}
    format={%{style: "currency", currency: "USD", maximum_fraction_digits: 0}}
  />
  <.number_input
    name="max_price"
    label="Max"
    size="sm"
    value={@max_price}
    min={0}
    max={5000}
    step={50}
    large_step={500}
    format={%{style: "currency", currency: "USD", maximum_fraction_digits: 0}}
  />
</form>
.ex
def handle_event("filter", %{"min_price" => _min, "max_price" => _max}, socket) do
  # Re-run the catalog query with the new bounds
  {:noreply, socket}
end

Stock-limited stepper

max={@in_stock} reads from socket assigns. The + button clamps and the help text updates as the limit is approached.

Linen tote

Limited release

7 in stock
7 available.
.heex
<form phx-change="qty_changed">
  <.number_input
    name="quantity"
    label="Quantity"
    help_text={help_text(@quantity, @in_stock)}
    value={@quantity}
    min={1}
    max={@in_stock}
    step={1}
  />
</form>
.ex
def handle_event("qty_changed", %{"quantity" => value}, socket) do
  # Clamp the typed value to the current stock and store it
  {:noreply, socket}
end

Validation errors

Bind field to a Phoenix.HTML.FormField and the input renders translated changeset errors automatically once the field has been touched.

A percentage between 0 and 100.
.heex
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
  <.number_input
    field={f[:price]}
    label="Price"
    min={0}
    step={0.01}
    format={%{style: "currency", currency: "USD", minimum_fraction_digits: 2}}
  />
  <.number_input
    field={f[:quantity]}
    label="Quantity"
    min={1}
    max={500}
    step={1}
  />
  <.number_input
    field={f[:discount_pct]}
    label="Discount"
    min={0}
    max={1}
    step={0.01}
    format={%{style: "percent", maximum_fraction_digits: 0}}
  />
</.form>
.ex
def handle_event("validate", %{"listing" => _params}, socket) do
  # Re-run validation and reassign the form
  {:noreply, socket}
end

def handle_event("save", %{"listing" => _params}, socket) do
  # Persist the listing
  {:noreply, socket}
end