shshadcn-htmx

Components

number-input

A native <input type="number"> with shadcn polish and optional −/+ steppers. The browser already makes it a role="spinbutton" with arrow-key stepping and value clamping — we only restyle and add larger buttons.

Installation

One file per stack. Use the shadcn CLI for JSX, or copy the source for your template engine.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/number-input.json

2. Use it

components/ui/number-input.tsx
import { NumberInput } from "@/components/ui/number-input"

<NumberInput name="qty" value={1} min={0} max={10} ariaLabel="Quantity" />
Or copy the source manually
components/ui/number-input.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Number Input — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui has no dedicated "number input" — it composes one from <Input
// type="number"> + Button (see repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/input.tsx
// for the Input we mirror). We do the same, but lean entirely on the platform.
//
// The control IS a native <input type="number">. Per MDN that element ships:
//   - implicit role="spinbutton"
//   - aria-valuenow / aria-valuemin / aria-valuemax auto-managed from
//     value / min / max — no manual ARIA
//   - the full APG spinbutton keyboard contract built in: ArrowUp increases,
//     ArrowDown decreases, plus standard single-line text editing
//   - stepUp() / stepDown() DOM methods used by the optional +/- buttons
//   See repos/mdn/files/en-us/web/html/reference/elements/input/number/index.md:298,468
//   APG: repos/aria-practices/content/patterns/spinbutton/spinbutton-pattern.html
//
// Because the native input already satisfies the spinbutton pattern, the only
// JS is a 3-line stepUp/stepDown handler for the optional buttons (public/site.js,
// keyed on data-slot="number-input"). With `steppers={false}` it is zero-JS.
//
// We hide the browser's default up/down spinners (they are tiny and inconsistent
// across engines) and supply our own larger, accessible buttons instead — the
// APG quantity-spinbutton example takes the same approach
// (repos/aria-practices/content/patterns/spinbutton/examples/quantity-spinbutton.html).

// Shared <Input> base — kept byte-for-byte in sync with registry/ui/input.tsx
// so a number input looks identical to every other field. We add rules to hide
// the native spinner buttons and reserve right padding when our own steppers
// sit inside the field.
const inputBase =
  "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none " +
  "selection:bg-primary selection:text-primary-foreground " +
  "placeholder:text-muted-foreground " +
  "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " +
  "md:text-sm dark:bg-input/30 " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
  "[&.htmx-request]:opacity-70 " +
  // Hide the native up/down spinner — Chromium (-webkit) + Firefox (-moz).
  "[&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"

// Stepper button: square, sized to the 9-unit field height, ghost styling that
// matches the muted/accent palette other controls use.
const stepperBtn =
  "inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none " +
  "hover:text-foreground hover:bg-accent " +
  "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 " +
  "disabled:pointer-events-none disabled:opacity-50"

export function numberInputClasses(opts?: { class?: ClassValue }): string {
  return cn(inputBase, opts?.class)
}

type NumberInputProps = {
  id?: string
  name?: string
  value?: number | string
  placeholder?: string
  min?: number | string
  max?: number | string
  step?: number | string
  required?: boolean
  disabled?: boolean
  readonly?: boolean
  autofocus?: boolean
  form?: string
  list?: string
  // Browser autofill hint — supported common attribute on <input type=number>
  // (e.g. "postal-code"). repos/mdn/.../elements/input/number/index.md:300,449
  autocomplete?: string

  // Render the styled −/+ stepper buttons around the field. Defaults to true.
  // When false the component is a bare native spinbutton (zero JS).
  steppers?: boolean

  // Mobile keyboard hint. "decimal" for prices, "numeric" for integers.
  inputmode?: "none" | "numeric" | "decimal"

  // ARIA / labelling. The native input is already role=spinbutton with
  // aria-valuenow/min/max derived from value/min/max.
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  ariaInvalid?: boolean | "grammar" | "spelling"
  ariaRequired?: boolean
  // Human-readable value for screen readers when the raw number isn't friendly
  // (currency/units). APG spinbutton: aria-practices/.../spinbutton-pattern.html:92
  ariaValuetext?: string

  class?: ClassValue

  // htmx v4 passthrough — fires on the input's change event by default. Use
  // hx-trigger to override. See repos/htmx/www/reference.md.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
}

export function NumberInput(props: NumberInputProps) {
  const {
    id,
    name,
    value,
    placeholder,
    min,
    max,
    step,
    required,
    disabled,
    readonly,
    autofocus,
    form,
    list,
    autocomplete,
    steppers = true,
    inputmode,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaInvalid,
    ariaRequired,
    ariaValuetext,
    class: className,
    ...rest
  } = props

  const field = (
    <input
      type="number"
      id={id}
      name={name}
      value={value}
      placeholder={placeholder}
      min={min}
      max={max}
      step={step}
      required={required}
      disabled={disabled}
      readonly={readonly}
      autofocus={autofocus}
      form={form}
      list={list}
      autocomplete={autocomplete}
      inputmode={inputmode}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-valuetext={ariaValuetext}
      aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
      aria-required={ariaRequired === undefined ? undefined : String(ariaRequired)}
      data-slot={steppers ? "number-input-field" : "number-input"}
      class={cn(inputBase, steppers && "rounded-none border-0 text-center shadow-none focus-visible:ring-0", className)}
      {...(steppers ? {} : rest)}
    />
  )

  if (!steppers) return field

  // Steppers on: a flex shell carries the border + focus ring (focus-within),
  // the bare buttons call stepDown()/stepUp() via public/site.js, and the
  // input keeps its native role=spinbutton + arrow-key contract.
  return (
    <div
      data-slot="number-input"
      data-disabled={disabled ? "true" : undefined}
      class={cn(
        "flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
        "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40",
        "has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50",
        className,
      )}
      {...rest}
    >
      <button
        type="button"
        data-step="down"
        tabindex={-1}
        disabled={disabled}
        aria-label="Decrease"
        title="Decrease"
        class={cn(stepperBtn, "rounded-l-md border-r border-input")}
      >
        <span aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <path d="M5 12h14" />
          </svg>
        </span>
      </button>
      {field}
      <button
        type="button"
        data-step="up"
        tabindex={-1}
        disabled={disabled}
        aria-label="Increase"
        title="Increase"
        class={cn(stepperBtn, "rounded-r-md border-l border-input")}
      >
        <span aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <path d="M5 12h14" />
            <path d="M12 5v14" />
          </svg>
        </span>
      </button>
    </div>
  )
}

1. Save the file

Copy number-input.html into templates/components/.

2. Use it

templates/components/number-input.html
{% from "components/number-input.html" import number_input %}

{{ number_input(name="qty", value=1, min=0, max=10, aria_label="Quantity") }}
View source
templates/components/number-input.html
{# Number Input macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/number-input.tsx for Python/Flask/FastAPI/Django/Jinja2.

   The control is a native <input type="number"> — role=spinbutton,
   aria-valuenow/min/max, and the ArrowUp/ArrowDown stepping contract all come
   from the platform. The optional −/+ buttons call stepDown()/stepUp() via the
   shared handler in public/site.js (keyed on data-slot="number-input").

   Usage:
       {% from "components/number-input.html" import number_input %}
       {{ number_input(name="qty", value=1, min=0, max=10) }}
       {{ number_input(name="price", min=0, step="0.01", inputmode="decimal", steppers=false) }}

   See repos/mdn/files/en-us/web/html/reference/elements/input/number/index.md. #}

{% macro number_input(
    id=none,
    name=none,
    value=none,
    placeholder=none,
    min=none,
    max=none,
    step=none,
    required=false,
    disabled=false,
    readonly=false,
    autofocus=false,
    form=none,
    list=none,
    autocomplete=none,
    steppers=true,
    inputmode=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    aria_invalid=none,
    aria_required=none,
    aria_valuetext=none,
    extra_class="",
    **attrs
) %}
{%- set input_base -%}
flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]
{%- endset -%}
{%- set stepper_btn -%}
inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50
{%- endset -%}
{%- macro field(slot) -%}
<input type="number"
       {%- if id %} id="{{ id }}"{% endif %}
       {%- if name %} name="{{ name }}"{% endif %}
       {%- if value is not none %} value="{{ value }}"{% endif %}
       {%- if placeholder %} placeholder="{{ placeholder }}"{% endif %}
       {%- if min is not none %} min="{{ min }}"{% endif %}
       {%- if max is not none %} max="{{ max }}"{% endif %}
       {%- if step is not none %} step="{{ step }}"{% endif %}
       {%- if required %} required{% endif %}
       {%- if disabled %} disabled{% endif %}
       {%- if readonly %} readonly{% endif %}
       {%- if autofocus %} autofocus{% endif %}
       {%- if form %} form="{{ form }}"{% endif %}
       {%- if list %} list="{{ list }}"{% endif %}
       {# autocomplete: supported common attr on <input type=number> — MDN input/number #}
       {%- if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}
       {%- if inputmode %} inputmode="{{ inputmode }}"{% endif %}
       {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
       {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
       {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
       {# aria-valuetext: human-readable spinbutton value — APG spinbutton pattern #}
       {%- if aria_valuetext %} aria-valuetext="{{ aria_valuetext }}"{% endif %}
       {%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
       {%- if aria_required is not none %} aria-required="{{ aria_required|string|lower }}"{% endif %}
       data-slot="{{ slot }}"
       class="{{ input_base }}{% if steppers %} rounded-none border-0 text-center shadow-none focus-visible:ring-0{% endif %} {{ extra_class }}"
       {%- if not steppers %}{% for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}{% endif -%}
>
{%- endmacro -%}
{%- if steppers -%}
<div data-slot="number-input"
     {%- if disabled %} data-disabled="true"{% endif %}
     class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50"
     {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
  <button type="button" data-step="down" tabindex="-1"{% if disabled %} disabled{% endif %} aria-label="Decrease" title="Decrease"
          class="{{ stepper_btn }} rounded-l-md border-r border-input">
    <span aria-hidden="true">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/></svg>
    </span>
  </button>
  {{ field("number-input-field") }}
  <button type="button" data-step="up" tabindex="-1"{% if disabled %} disabled{% endif %} aria-label="Increase" title="Increase"
          class="{{ stepper_btn }} rounded-r-md border-l border-input">
    <span aria-hidden="true">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
    </span>
  </button>
</div>
{%- else -%}
{{ field("number-input") }}
{%- endif -%}
{% endmacro %}

1. Save the file

Add number-input.tmpl alongside your other templates.

2. Use it

components/number-input.tmpl
tpl.ExecuteTemplate(w, "number-input", map[string]any{
    "Name": "qty", "Value": "1", "Min": "0", "Max": "10",
    "AriaLabel": "Quantity",
})
View source
components/number-input.tmpl
{{/*
  Number Input template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/number-input.tsx for Go projects using html/template.

  The control is a native <input type="number">: role=spinbutton,
  aria-valuenow/min/max, and the ArrowUp/ArrowDown stepping contract all come
  from the platform. The optional −/+ buttons call stepDown()/stepUp() via the
  shared handler in public/site.js (keyed on data-slot="number-input").

  Args (via dict):
      ID, Name, Value, Placeholder string
      Min, Max, Step               string  // numeric attrs, as strings
      Required, Disabled, Readonly bool
      Autofocus                    bool
      Form, List                   string
      Autocomplete                 string  // browser autofill hint, e.g. "postal-code"
      NoSteppers                   bool    // omit the −/+ buttons (bare field)
      InputMode                    string  // "" | "numeric" | "decimal"
      AriaLabel, AriaLabelledby    string
      AriaDescribedby              string
      AriaValuetext                string  // human-readable value for screen readers
      AriaInvalid, AriaRequired    string  // "true" | "false"
      Attrs                        map[string]string // hx-*, data-*, …

      {{template "number-input" (dict "Name" "qty" "Value" "1" "Min" "0" "Max" "10")}}

  See repos/mdn/files/en-us/web/html/reference/elements/input/number/index.md.
*/}}

{{define "number-input"}}
{{- $steppers := not .NoSteppers -}}
{{- $inputBase := "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]" -}}
{{- $stepperBtn := "inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50" -}}
{{- if $steppers -}}
<div data-slot="number-input"
     {{- if .Disabled}} data-disabled="true"{{end}}
     class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50"
     {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
  <button type="button" data-step="down" tabindex="-1"{{if .Disabled}} disabled{{end}} aria-label="Decrease" title="Decrease"
          class="{{$stepperBtn}} rounded-l-md border-r border-input">
    <span aria-hidden="true">{{htmlSafe "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"size-4\"><path d=\"M5 12h14\"/></svg>"}}</span>
  </button>
  <input type="number"
       {{- if .ID}} id="{{.ID}}"{{end}}
       {{- if .Name}} name="{{.Name}}"{{end}}
       {{- if .Value}} value="{{.Value}}"{{end}}
       {{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
       {{- if .Min}} min="{{.Min}}"{{end}}
       {{- if .Max}} max="{{.Max}}"{{end}}
       {{- if .Step}} step="{{.Step}}"{{end}}
       {{- if .Required}} required{{end}}
       {{- if .Disabled}} disabled{{end}}
       {{- if .Readonly}} readonly{{end}}
       {{- if .Autofocus}} autofocus{{end}}
       {{- if .Form}} form="{{.Form}}"{{end}}
       {{- if .List}} list="{{.List}}"{{end}}
       {{- /* autocomplete: supported common attr on <input type=number> — MDN input/number */ -}}
       {{- if .Autocomplete}} autocomplete="{{.Autocomplete}}"{{end}}
       {{- if .InputMode}} inputmode="{{.InputMode}}"{{end}}
       {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
       {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
       {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
       {{- /* aria-valuetext: human-readable spinbutton value — APG spinbutton pattern */ -}}
       {{- if .AriaValuetext}} aria-valuetext="{{.AriaValuetext}}"{{end}}
       {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
       {{- if .AriaRequired}} aria-required="{{.AriaRequired}}"{{end}}
       data-slot="number-input-field"
       class="{{$inputBase}} rounded-none border-0 text-center shadow-none focus-visible:ring-0">
  <button type="button" data-step="up" tabindex="-1"{{if .Disabled}} disabled{{end}} aria-label="Increase" title="Increase"
          class="{{$stepperBtn}} rounded-r-md border-l border-input">
    <span aria-hidden="true">{{htmlSafe "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"size-4\"><path d=\"M5 12h14\"/><path d=\"M12 5v14\"/></svg>"}}</span>
  </button>
</div>
{{- else -}}
<input type="number"
     {{- if .ID}} id="{{.ID}}"{{end}}
     {{- if .Name}} name="{{.Name}}"{{end}}
     {{- if .Value}} value="{{.Value}}"{{end}}
     {{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
     {{- if .Min}} min="{{.Min}}"{{end}}
     {{- if .Max}} max="{{.Max}}"{{end}}
     {{- if .Step}} step="{{.Step}}"{{end}}
     {{- if .Required}} required{{end}}
     {{- if .Disabled}} disabled{{end}}
     {{- if .Readonly}} readonly{{end}}
     {{- if .Autofocus}} autofocus{{end}}
     {{- if .Form}} form="{{.Form}}"{{end}}
     {{- if .List}} list="{{.List}}"{{end}}
     {{- /* autocomplete: supported common attr on <input type=number> — MDN input/number */ -}}
     {{- if .Autocomplete}} autocomplete="{{.Autocomplete}}"{{end}}
     {{- if .InputMode}} inputmode="{{.InputMode}}"{{end}}
     {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
     {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
     {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
     {{- /* aria-valuetext: human-readable spinbutton value — APG spinbutton pattern */ -}}
     {{- if .AriaValuetext}} aria-valuetext="{{.AriaValuetext}}"{{end}}
     {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
     {{- if .AriaRequired}} aria-required="{{.AriaRequired}}"{{end}}
     data-slot="number-input"
     class="{{$inputBase}}"
     {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
{{- end -}}
{{end}}

1. Save the file

Drop number_input.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/number_input.ex
<.number_input name="qty" value={1} min={0} max={10} aria-label="Quantity" />
View source
lib/my_app_web/components/number_input.ex
defmodule ShadcnHtmx.Components.NumberInput do
  @moduledoc """
  Number Input — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/number-input.tsx. The control is a native
  `<input type="number">`, so role=spinbutton, aria-valuenow/min/max, and the
  ArrowUp/ArrowDown stepping contract all come from the platform. The optional
  −/+ buttons call `stepDown()` / `stepUp()` through the shared handler in
  public/site.js (keyed on `data-slot="number-input"`).

  ## Examples

      <.number_input name="qty" value={1} min={0} max={10} aria-label="Quantity" />
      <.number_input name="price" min={0} step="0.01" inputmode="decimal" steppers={false} />

  See repos/mdn/files/en-us/web/html/reference/elements/input/number/index.md.
  """

  use Phoenix.Component

  @input_base "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs " <>
                "transition-[color,box-shadow] outline-none " <>
                "selection:bg-primary selection:text-primary-foreground " <>
                "placeholder:text-muted-foreground " <>
                "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " <>
                "md:text-sm dark:bg-input/30 " <>
                "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
                "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
                "[&.htmx-request]:opacity-70 " <>
                "[&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"

  @stepper_btn "inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none " <>
                 "hover:text-foreground hover:bg-accent " <>
                 "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 " <>
                 "disabled:pointer-events-none disabled:opacity-50"

  attr :steppers, :boolean, default: true
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil

  attr :rest, :global,
    include:
      # autocomplete: supported common attr on <input type=number> (MDN input/number).
      # aria-valuetext: human-readable spinbutton value (APG spinbutton pattern).
      ~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-indicator hx-vals hx-include
         id name value placeholder min max step required readonly autofocus form list autocomplete inputmode
         aria-label aria-labelledby aria-describedby aria-valuetext aria-invalid aria-required)

  def number_input(assigns) do
    assigns =
      assigns
      |> assign(:input_base, @input_base)
      |> assign(:stepper_btn, @stepper_btn)

    ~H"""
    <div
      :if={@steppers}
      data-slot="number-input"
      data-disabled={@disabled && "true"}
      class={[
        "flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
        "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40",
        "has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50",
        @class
      ]}
    >
      <button
        type="button"
        data-step="down"
        tabindex="-1"
        disabled={@disabled}
        aria-label="Decrease"
        title="Decrease"
        class={[@stepper_btn, "rounded-l-md border-r border-input"]}
      >
        <span aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <path d="M5 12h14" />
          </svg>
        </span>
      </button>
      <input
        type="number"
        disabled={@disabled}
        data-slot="number-input-field"
        class={[@input_base, "rounded-none border-0 text-center shadow-none focus-visible:ring-0"]}
        {@rest}
      />
      <button
        type="button"
        data-step="up"
        tabindex="-1"
        disabled={@disabled}
        aria-label="Increase"
        title="Increase"
        class={[@stepper_btn, "rounded-r-md border-l border-input"]}
      >
        <span aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <path d="M5 12h14" />
            <path d="M12 5v14" />
          </svg>
        </span>
      </button>
    </div>
    <input
      :if={!@steppers}
      type="number"
      disabled={@disabled}
      data-slot="number-input"
      class={[@input_base, @class]}
      {@rest}
    />
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css. The included <script> wires the buttons standalone.

2. Use it

snippets/number-input.html
<div data-slot="number-input" class="flex h-9 … rounded-md border …">
  <button type="button" data-step="down" tabindex="-1" aria-label="Decrease">…</button>
  <input type="number" name="qty" value="1" min="0" max="10"
         data-slot="number-input-field" class="… text-center …">
  <button type="button" data-step="up" tabindex="-1" aria-label="Increase">…</button>
</div>
View source
snippets/number-input.html
<!--
  shadcn-htmx — raw Number Input snippets.

  Mirrors registry/ui/number-input.tsx. Drop onto any page that loads Tailwind
  CSS v4 and the shadcn theme variables (background, foreground, input, ring,
  destructive, accent, muted-foreground). See app/styles/input.css for defaults.

  The control is a native <input type="number"> — role=spinbutton,
  aria-valuenow/min/max, and the ArrowUp/ArrowDown stepping contract all come
  from the platform (see MDN). The −/+ buttons call stepDown()/stepUp(); the
  inline boot script below wires them (data-slot="number-input"). In the docs
  app the same handler lives in public/site.js, so you can delete the script
  there. With the bare-field variant there is no JS at all.

  INPUT BASE (shared with Input + hides native spinners):
    flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent
    px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none
    selection:bg-primary selection:text-primary-foreground
    placeholder:text-muted-foreground
    disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
    md:text-sm dark:bg-input/30
    focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
    aria-invalid:border-destructive aria-invalid:ring-destructive/20
    dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70
    [&::-webkit-inner-spin-button]:appearance-none
    [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]
-->

<!-- ─── With steppers (default) ─────────────────────────────────────── -->
<div data-slot="number-input"
  class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50">
  <button type="button" data-step="down" tabindex="-1" aria-label="Decrease" title="Decrease"
    class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-l-md border-r border-input">
    <span aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/></svg></span>
  </button>
  <!-- aria-valuetext: human-readable spinbutton value for AT (APG spinbutton pattern). -->
  <input type="number" name="qty" value="1" min="0" max="10" aria-valuetext="1 item" data-slot="number-input-field"
    class="flex h-9 w-full min-w-0 rounded-none border-0 bg-transparent px-3 py-1 text-base text-center shadow-none outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]">
  <button type="button" data-step="up" tabindex="-1" aria-label="Increase" title="Increase"
    class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-r-md border-l border-input">
    <span aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/><path d="M12 5v14"/></svg></span>
  </button>
</div>

<!-- ─── Bare field, no buttons — zero JS ────────────────────────────── -->
<!-- Still a full spinbutton: ArrowUp/ArrowDown step, native value clamping. -->
<!-- autocomplete: supported common attr on <input type=number> for browser autofill (MDN input/number). -->
<input type="number" name="amount" min="0" step="0.01" inputmode="decimal" placeholder="0.00" autocomplete="transaction-amount" data-slot="number-input"
  class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]">

<!-- ─── Disabled ────────────────────────────────────────────────────── -->
<div data-slot="number-input" data-disabled="true"
  class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs dark:bg-input/30 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50">
  <button type="button" data-step="down" tabindex="-1" disabled aria-label="Decrease" title="Decrease"
    class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground select-none disabled:pointer-events-none disabled:opacity-50 rounded-l-md border-r border-input">
    <span aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/></svg></span>
  </button>
  <input type="number" value="3" disabled data-slot="number-input-field"
    class="flex h-9 w-full min-w-0 rounded-none border-0 bg-transparent px-3 py-1 text-base text-center shadow-none outline-none md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]">
  <button type="button" data-step="up" tabindex="-1" disabled aria-label="Increase" title="Increase"
    class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground select-none disabled:pointer-events-none disabled:opacity-50 rounded-r-md border-l border-input">
    <span aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M5 12h14"/><path d="M12 5v14"/></svg></span>
  </button>
</div>

<!--
  Boot script — wires the −/+ buttons to the native stepDown()/stepUp().
  Self-contained; safe to include once per page. (The docs site ships the
  same logic in public/site.js, so omit this there.)
-->
<script>
  (function () {
    document.addEventListener('click', function (e) {
      var btn = e.target.closest && e.target.closest('[data-slot="number-input"] [data-step]')
      if (!btn || btn.disabled) return
      var root = btn.closest('[data-slot="number-input"]')
      var input = root && root.querySelector('input[type="number"]')
      if (!input || input.disabled || input.readOnly) return
      try {
        if (btn.getAttribute('data-step') === 'up') input.stepUp()
        else input.stepDown()
      } catch (err) { return }
      input.dispatchEvent(new Event('input', { bubbles: true }))
      input.dispatchEvent(new Event('change', { bubbles: true }))
    })
  })()
</script>

Examples

Stepper buttons

−/+ buttons call the native stepUp()/stepDown(); the field stays a real spinbutton, so ArrowUp/ArrowDown still work too.

The buttons are tabindex="-1" and the input keeps focus — exactly what the APG spinbutton pattern prescribes: the increment/decrement controls are redundant with the arrow keys, so they stay out of the tab order. The browser enforces min, max and step and reports aria-valuenow automatically.

<NumberInput name="qty" value={1} min={0} max={10} ariaLabel="Quantity" />
{{ number_input(name="qty", value=1, min=0, max=10, aria_label="Quantity") }}
{{template "number-input" (dict "Name" "qty" "Value" "1" "Min" "0" "Max" "10" "AriaLabel" "Quantity")}}
<.number_input name="qty" value={1} min={0} max={10} aria-label="Quantity" />
<div class="grid w-full max-w-3xs gap-2">
  <label class="text-xs font-medium" for="ex-basic-qty">Quantity</label>
  <div data-slot="number-input" class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50">
    <button type="button" data-step="down" tabindex="-1" aria-label="Decrease" title="Decrease" class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-l-md border-r border-input">
      <span aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
          <path d="M5 12h14">
          </path>
        </svg>
      </span>
    </button>
    <input type="number" id="ex-basic-qty" name="qty" value="1" min="0" max="10" data-slot="number-input-field" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70 [&amp;::-webkit-inner-spin-button]:appearance-none [&amp;::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield] rounded-none border-0 text-center shadow-none focus-visible:ring-0"/>
    <button type="button" data-step="up" tabindex="-1" aria-label="Increase" title="Increase" class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-r-md border-l border-input">
      <span aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
          <path d="M5 12h14">
          </path>
          <path d="M12 5v14">
          </path>
        </svg>
      </span>
    </button>
  </div>
</div>

Bare field — zero JavaScript

steppers={false} drops the buttons. It is still a full spinbutton: arrow keys step, the browser clamps to min/max.

When you don't want the buttons, the component renders the plain native input — no wrapper, no script. Pair inputmode="decimal" with step="0.01" for currency, and the mobile keyboard shows the decimal point.

<NumberInput name="price" min={0} step="0.01"
            inputmode="decimal" placeholder="0.00" steppers={false} />
{{ number_input(name="price", min=0, step="0.01",
                inputmode="decimal", placeholder="0.00", steppers=false) }}
{{template "number-input" (dict
  "Name" "price" "Min" "0" "Step" "0.01"
  "InputMode" "decimal" "Placeholder" "0.00" "NoSteppers" true)}}
<.number_input name="price" min={0} step="0.01"
              inputmode="decimal" placeholder="0.00" steppers={false} />
<div class="grid w-full max-w-xs gap-2">
  <label class="text-xs font-medium" for="ex-bare-price">Price</label>
  <input type="number" id="ex-bare-price" name="price" placeholder="0.00" min="0" step="0.01" inputmode="decimal" data-slot="number-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70 [&amp;::-webkit-inner-spin-button]:appearance-none [&amp;::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"/>
</div>

Further reading

htmx — live total

On every change, htmx POSTs the quantity and the server returns the running total. No client state.

htmx fires on the input's native change event — which the stepper buttons dispatch too — so clicking −/+ or arrow-keying both trigger the request. hx-target points at the total node and hx-swap="innerHTML" drops in the server's answer.

Total: $12

<NumberInput name="qty" value={1} min={0} max={20}
            hx-post="/api/total" hx-target="#total"
            hx-swap="innerHTML" hx-trigger="change" />
<span id="total" aria-live="polite"></span>
{{ number_input(name="qty", value=1, min=0, max=20,
                hx_post="/api/total", hx_target="#total",
                hx_swap="innerHTML", hx_trigger="change") }}
<span id="total" aria-live="polite"></span>
{{template "number-input" (dict
  "Name" "qty" "Value" "1" "Min" "0" "Max" "20"
  "Attrs" (dict
    "hx-post" "/api/total" "hx-target" "#total"
    "hx-swap" "innerHTML" "hx-trigger" "change"
  ))}}
<.number_input name="qty" value={1} min={0} max={20}
              hx-post="/api/total" hx-target="#total"
              hx-swap="innerHTML" hx-trigger="change" />
<div class="grid w-full max-w-3xs gap-3">
  <label class="text-xs font-medium" for="ex-htmx-qty">Tickets ($12 each)</label>
  <div data-slot="number-input" class="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] dark:bg-input/30 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40 has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50" hx-post="/number-input/total" hx-target="#ex-htmx-total" hx-swap="innerHTML" hx-trigger="change">
    <button type="button" data-step="down" tabindex="-1" aria-label="Decrease" title="Decrease" class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-l-md border-r border-input">
      <span aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
          <path d="M5 12h14">
          </path>
        </svg>
      </span>
    </button>
    <input type="number" id="ex-htmx-qty" name="qty" value="1" min="0" max="20" data-slot="number-input-field" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70 [&amp;::-webkit-inner-spin-button]:appearance-none [&amp;::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield] rounded-none border-0 text-center shadow-none focus-visible:ring-0"/>
    <button type="button" data-step="up" tabindex="-1" aria-label="Increase" title="Increase" class="inline-flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none select-none hover:text-foreground hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:relative focus-visible:z-10 disabled:pointer-events-none disabled:opacity-50 rounded-r-md border-l border-input">
      <span aria-hidden="true">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
          <path d="M5 12h14">
          </path>
          <path d="M12 5v14">
          </path>
        </svg>
      </span>
    </button>
  </div>
  <p class="text-sm text-muted-foreground" aria-live="polite">
    Total:
    <span id="ex-htmx-total" class="font-medium text-foreground">$12</span>
  </p>
</div>

API Reference

<NumberInput>

PropTypeDefaultDescription
autocompletestring
Browser autofill hint forwarded to the native input (a supported common attribute on input type=number), e.g. "postal-code" or "transaction-amount". MDN input/number Accessibility section.
ariaValuetextstring
Human-readable value announced by screen readers when the raw number is not user-friendly (currency, units, formatted quantities). Rendered as aria-valuetext on the spinbutton input. APG spinbutton pattern.
steppersbooleantrue
Render the styled −/+ buttons around the field. false renders a bare native spinbutton (zero JS) — arrow keys still step.
valuenumber|string
Initial value. Reflected to aria-valuenow by the browser.
minnumber|string
Minimum allowed value; the browser clamps and exposes it as aria-valuemin.MDNmin
maxnumber|string
Maximum allowed value; the browser clamps and exposes it as aria-valuemax.MDNmax
stepnumber|string"1"
Increment per arrow press / button click. Use "any" to allow arbitrary decimals.MDNstep
inputmode"none"|"numeric"|"decimal"
Mobile keyboard hint — decimal for prices, numeric for integers.MDNinputmode
readonlybooleanfalse
Read-only — focusable + selectable but not editable, and not stepped by the buttons.
disabledbooleanfalse
Disable the field and both stepper buttons; not submitted with the form.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference