Input Number
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/input-numberPlain-CSS theme — no build step
npx astrocraft-ui add forms/input-number --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/input-number --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/input-number --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/input-number --theme tailwind --bridge lumosLive demo
Input Number
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `InputNumber.astro` | `input-number` `input-number-decrement` `input-number-field` `input-number-increment` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
Source
What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/input-number/InputNumber.astro — headless primitive (see ../../README.md).
// Native <input type="number"> flanked by decrement / increment steppers. The small bundled script
// drives stepDown()/stepUp() (which honor native min/max/step), keeps the field in sync, and disables
// a stepper once its bound is reached. Without JS it degrades to a plain number field with the OS
// spinners. Give it an accessible name via an associated <Label> (`id`/`for`) or `aria-label`.
// The steppers carry Button's `data-variant` / `data-size` surface so one theme rule styles them
// with the rest of the buttons.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const { size = "md", state = "default", class: className, ...rest } = Astro.props;
---
<div data-slot="input-number">
<button
type="button"
data-slot="input-number-decrement"
data-variant="outline"
data-size={size}
aria-label="Decrease"
>
<slot name="decrement"
><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"
aria-hidden="true"
>
<path d="M5 12h14"></path>
</svg></slot
>
</button>
<input
type="number"
inputmode="numeric"
data-slot="input-number-field"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
<button
type="button"
data-slot="input-number-increment"
data-variant="outline"
data-size={size}
aria-label="Increase"
>
<slot name="increment"
><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"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14"></path>
</svg></slot
>
</button>
</div>
<script>
import { onReadyOnce } from "../../_once";
function wire(root: HTMLElement) {
const field = root.querySelector<HTMLInputElement>('[data-slot="input-number-field"]');
const dec = root.querySelector<HTMLButtonElement>('[data-slot="input-number-decrement"]');
const inc = root.querySelector<HTMLButtonElement>('[data-slot="input-number-increment"]');
if (!field) return;
const bound = (attr: "min" | "max", fallback: number) =>
field[attr] === "" ? fallback : Number(field[attr]);
const sync = () => {
const v = field.valueAsNumber;
const atMin = !Number.isNaN(v) && v <= bound("min", -Infinity);
const atMax = !Number.isNaN(v) && v >= bound("max", Infinity);
if (dec) dec.disabled = field.disabled || atMin;
if (inc) inc.disabled = field.disabled || atMax;
};
const step = (dir: 1 | -1) => {
if (field.disabled) return;
// A blank/invalid field has no number to step from — seed it at min (or 0) first.
if (Number.isNaN(field.valueAsNumber)) field.value = field.min || "0";
else if (dir > 0) field.stepUp();
else field.stepDown();
field.dispatchEvent(new Event("input", { bubbles: true }));
field.dispatchEvent(new Event("change", { bubbles: true }));
sync();
};
dec?.addEventListener("click", () => step(-1));
inc?.addEventListener("click", () => step(1));
field.addEventListener("input", sync);
sync();
}
onReadyOnce('[data-slot="input-number"]', wire);
</script>
import InputNumber from "./InputNumber.astro";
export { InputNumber };
export default InputNumber;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/input-number — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.