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.json2. Use it
import { NumberInput } from "@/components/ui/number-input"
<NumberInput name="qty" value={1} min={0} max={10} ariaLabel="Quantity" />Or copy the source manually
/** @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
{% from "components/number-input.html" import number_input %}
{{ number_input(name="qty", value=1, min=0, max=10, aria_label="Quantity") }}View source
{# 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
tpl.ExecuteTemplate(w, "number-input", map[string]any{
"Name": "qty", "Value": "1", "Min": "0", "Max": "10",
"AriaLabel": "Quantity",
})View source
{{/*
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
<.number_input name="qty" value={1} min={0} max={10} aria-label="Quantity" />View source
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
<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
<!--
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 [&.htmx-request]:opacity-70 [&::-webkit-inner-spin-button]:appearance-none [&::-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 [&.htmx-request]:opacity-70 [&::-webkit-inner-spin-button]:appearance-none [&::-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 [&.htmx-request]:opacity-70 [&::-webkit-inner-spin-button]:appearance-none [&::-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>Further reading
API Reference
<NumberInput>
| Prop | Type | Default | Description |
|---|---|---|---|
autocomplete | string | — | 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. |
ariaValuetext | string | — | 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. |
steppers | boolean | true | Render the styled −/+ buttons around the field. false renders a bare native spinbutton (zero JS) — arrow keys still step. |
value | number|string | — | Initial value. Reflected to aria-valuenow by the browser. |
min | number|string | — | Minimum allowed value; the browser clamps and exposes it as aria-valuemin.MDNmin |
max | number|string | — | Maximum allowed value; the browser clamps and exposes it as aria-valuemax.MDNmax |
step | number|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 |
readonly | boolean | false | Read-only — focusable + selectable but not editable, and not stepped by the buttons. |
disabled | boolean | false | Disable the field and both stepper buttons; not submitted with the form. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |