A numeric spinbutton: one real input on the input-group surface, with decrement and increment buttons, clamping, and a full keyboard map.
Reach for it wherever a quantity, a price or a percentage is typed. It
replaces <input type="number">, whose spinners are unstyleable and
inconsistent across browsers, and whose value sanitising fights any kind of
display formatting.
Why not type="number"
The control renders <input type="text" inputmode="decimal"> carrying
role="spinbutton", the WAI-ARIA pattern for exactly this widget. That buys:
- spinners we draw ourselves, identical in every browser
precisionformatting on blur, whichtype="number"silently discards- a value that stays readable while editing, since the browser never "sanitises" a half-typed number to the empty string
- the numeric keypad on mobile, via
inputmode
What it costs: native constraint validation. min and max are enforced by
the hook and mirrored to aria-valuemin / aria-valuemax, not by the
browser, so validate the bound on the server too - the same as you would
for any user-supplied number.
Examples
<.number_field name="quantity" value="1" min={1} max={99} />
<.number_field field={@form[:quantity]} min={1} max={99} />
<.number_field field={@form[:price]} variant="split" precision={2} step={0.5}>
<:leading>$</:leading>
</.number_field>
<.number_field field={@form[:share]} min={0} max={100} step={5} big_step={25}>
<:trailing>%</:trailing>
</.number_field>Inside <.field> it gets a label, help text and error styling for free:
<.field type="number-field" field={@form[:quantity]} label="Quantity" min={1} />Variants
stacked(default) - chevron up/down stacked at the inline endsplit- minus at the inline start, plus at the inline end, value centredplain- no buttons; typing, arrows and wheel only
Keyboard and pointer
| Input | Effect |
|---|---|
ArrowUp / ArrowDown | step by step |
Shift + arrow | step by big_step (defaults to step * 10) |
PageUp / PageDown | step by big_step |
Home / End | jump to min / max when set |
| Wheel | steps while the input is focused, and only then |
| Press and hold a button | one step, then repeat, accelerating |
Typed text is never clamped mid-keystroke - it is clamped and formatted on
blur, so 1 on the way to 15 does not snap to the maximum under your
fingers.
Accessibility
The input is the single tab stop: the buttons are tabindex="-1" with
aria-labels, the way the APG spinbutton pattern prescribes. aria-valuenow
tracks the value on every change, and a button at its bound gets
aria-disabled rather than disabled, so it stays discoverable to a screen
reader. A disabled control uses the native attribute throughout.
Inside <.field> the label wires up automatically. Standalone, the input
has NO accessible name - the APG pattern requires one, so pass
aria-label (it rides through the global attrs) or reference a visible
label with aria-labelledby.
Formatting beyond precision
precision rounds and pads to a fixed number of decimals on blur. That is the
whole built-in formatting story - no locale engine, no masking dependency.
For currency or percent display, format the visible text on blur and keep the
raw number in the posted value, using the platform's own Intl.NumberFormat:
// in your app's JS, alongside the petal hooks
export const PriceField = {
mounted() {
const input = this.el.querySelector("[data-pc-number-input]");
const fmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
input.addEventListener("focus", () => {
input.value = input.dataset.raw ?? input.value;
});
input.addEventListener("blur", () => {
const n = parseFloat(input.value);
if (Number.isNaN(n)) return;
input.dataset.raw = String(n);
input.value = fmt.format(n);
});
}
};Post the raw number in a hidden input if the formatted text would confuse your changeset.
Setup
Needs the PetalNumberField hook from the petal_components JS bundle:
import PetalComponents from "../../deps/petal_components/assets/js/petal_components.js"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...PetalComponents }
})Without the hook it degrades to a plain text input that still posts its value.
Summary
Functions
Renders a numeric spinbutton. Full documentation, including the keyboard map
and the Intl.NumberFormat pattern, lives on PetalComponents.NumberField.
The input id this component would render for a given id and name.
Functions
Renders a numeric spinbutton. Full documentation, including the keyboard map
and the Intl.NumberFormat pattern, lives on PetalComponents.NumberField.
Attributes
id(:any) - input id; generated from the field or name if not passed. Defaults tonil.name(:any) - input name; generated from the field if not passed.value(:any) - current value; generated from the field if not passed.field(Phoenix.HTML.FormField) - a form field struct, e.g. @form[:quantity]; sets id, name and value like other inputs.min(:any) - lower bound; values are clamped and mirrored to aria-valuemin. Defaults tonil.max(:any) - upper bound; clamped and mirrored to aria-valuemax. Defaults tonil.step(:any) - increment for the buttons, arrow keys and wheel. Defaults to1.big_step(:any) - increment for shift+arrow and page up/down; defaults to step * 10. Defaults tonil.precision(:integer) - decimal places shown on blur; the raw text stands while editing. nil means no formatting. Defaults tonil.variant(:string) - stacked: both buttons at the inline end; split: minus at the start, plus at the end; plain: no buttons. Defaults to"stacked". Must be one of"stacked","split", or"plain".size(:string) - input height and text size. Defaults to"md". Must be one of"sm","md", or"lg".disabled(:boolean) - disables the input and both buttons natively. Defaults tofalse.decrement_label(:string) - accessible name for the decrement button. Defaults to"Decrease value".increment_label(:string) - accessible name for the increment button. Defaults to"Increase value".class(:any) - extra classes for the field surface. Defaults tonil.- Global attributes are accepted. all other attributes land on the input. Supports all globals plus:
["autocomplete", "form", "placeholder", "readonly", "required", "inputmode", "aria-describedby"].
Slots
leading- addon before the input, e.g. a currency symbol.trailing- addon after the input, e.g. a unit.
The input id this component would render for a given id and name.
<.field type="number-field"> calls it so the label's for= names the same
control the hook addresses. One identity, no way for the two to drift.