Number Input
A numeric text field with increment (+) and decrement (−) spin-buttons. Enforces min/max bounds, supports configurable step sizes, and integrates with HTML forms.
Features
Keyboard Navigation — ↑/↓arrows step bystep;Page Up/Page Downstep bylarge-stepSpin Buttons — click or hold to increment / decrement 6 Semantic Colors — primary, secondary, info, success, warning, error 5 Variants — solid, flat, bordered, outline, ghost 3 Sizes — sm, md, lg Form-Associated — nameattribute & native formresetsupportNullable Mode — allows an empty / null state Min / Max Clamping — values are automatically clamped to the configured range
Source Code
View Source Code
ts
import { clamp } from '@vielzeug/arsenal';
import { define, html, inject, prop, ref } from '@vielzeug/craft';
import { computed, signal, watch as rippleWatch } from '@vielzeug/ripple';
import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';
import { createSpinnerControl } from '../../headless';
import '../../content/icon/icon';
import '../input/input';
import { disablableBundle, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import { disabledStateMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import styles from './number-input.css?inline';
export type SgNumberInputEvents = {
change: { originalEvent?: Event; value: number | null };
input: { originalEvent?: Event; value: number | null };
};
/** Number Input props */
export type SgNumberInputProps = {
/** Theme color */
color?: ThemeColor;
/** Disable interaction */
disabled?: boolean;
/** Error message */
error?: string;
/** Stretch to full width of container */
fullwidth?: boolean;
/** Helper text */
helper?: string;
/** Visible label */
label?: string;
/** Label placement: 'inset' renders the label inside the control box, 'outside' renders it above */
'label-placement'?: 'inset' | 'outside';
/** Large step (for Page Up/Down, default: 10 × step) */
'large-step'?: number;
/** Maximum allowed value */
max?: number;
/** Minimum allowed value */
min?: number;
/** Form field name */
name?: string;
/** Placeholder text */
placeholder?: string;
/** Make the input read-only */
readonly?: boolean;
/**
* JS-only callback fired with the inner `<input>` element when it mounts,
* and with `null` when it unmounts.
* Set as a JS property: `bitNumberInput.ref = (el) => { ... }`.
*/
ref?: ((el: HTMLInputElement | null) => void) | null;
/** Border radius */
rounded?: string;
/** Component size */
size?: ComponentSize;
/** Step size for increment/decrement */
step?: number;
/** Current numeric value */
value?: number;
/** Visual variant */
variant?: VisualVariant;
};
/**
* A numeric spin-button input with +/− controls, min/max clamping, and full keyboard support.
*
* @element sg-number-input
*
* @attr {number} value - Current value
* @attr {number} min - Minimum value
* @attr {number} max - Maximum value
* @attr {number} step - Increment/decrement step (default: 1)
* @attr {number} large-step - Step for Page Up/Down (default: 10)
* @attr {boolean} disabled - Disables the control
* @attr {boolean} readonly - Read-only mode
* @attr {string} label - Visible label
* @attr {string} name - Form field name
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} size - 'sm' | 'md' | 'lg'
* @attr {string} placeholder - Input placeholder
*
* @fires change - On committed value change. detail: { value: number | null, originalEvent?: Event }
* @fires input - On every keystroke. detail: { value: number | null, originalEvent?: Event }
*
* @slot prefix - Content before the input (e.g. icon)
* @slot suffix - Content after the input (e.g. unit label)
* @slot label - Custom label content
* @slot helper - Custom helper text content
* @slot error - Custom error content
*
* @cssprop --number-input-height - Control height
* @cssprop --number-input-border-color - Border color
* @cssprop --number-input-radius - Border radius
* @cssprop --number-input-bg - Background
* @cssprop --number-input-btn-bg - Spin button background
*
* @part control - Control container.
* @part decrement-btn - Decrement stepper button.
* @part input - Input element.
* @part increment-btn - Increment stepper button.
* @example
* ```html
* <sg-number-input label="Quantity" value="1" min="1" max="99" step="1"></sg-number-input>
* ```
*/
export const NUMBER_INPUT_TAG = 'sg-number-input' as const;
define<SgNumberInputProps, SgNumberInputEvents>(NUMBER_INPUT_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
...roundableBundle,
error: prop.string(),
fullwidth: prop.bool(false),
helper: prop.string(),
label: prop.string(),
'label-placement': prop.oneOf(['inset', 'outside'] as const, 'inset'),
'large-step': prop.json(undefined as number | undefined),
max: prop.json(undefined as number | undefined),
min: prop.json(undefined as number | undefined),
name: prop.string(),
placeholder: prop.string(),
readonly: prop.bool(false),
ref: prop.json(undefined as ((el: HTMLInputElement | null) => void) | null | undefined),
step: prop.number(1),
value: prop.json(undefined as number | undefined),
variant: prop.string<VisualVariant>(),
},
setup(props, { bind, emit, onElement, watch }) {
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
const isDisabled = fCtxProps.disabled;
const isReadonly = computed(() => Boolean(props.readonly.value));
// Internal numeric value signal (string representation for the input)
const fieldValue = signal(props.value.value != null ? String(props.value.value) : '');
// Keep fieldValue in sync when props.value changes externally
rippleWatch(props.value, (v) => {
const next = v != null ? String(v) : '';
if (fieldValue.value !== next) fieldValue.value = next;
});
function parseValue(): number | null {
const v = fieldValue.value.trim();
if (!v) return null;
const n = Number.parseFloat(v);
return Number.isNaN(n) ? null : n;
}
function commit(val: number | null, originalEvent?: Event) {
const min = props.min.value != null ? Number(props.min.value) : undefined;
const max = props.max.value != null ? Number(props.max.value) : undefined;
const clamped = val != null ? clamp(val, min, max) : null;
const nextValue = clamped != null ? String(clamped) : '';
if (fieldValue.value !== nextValue) fieldValue.value = nextValue;
emit('change', { originalEvent, value: clamped });
}
const spinner = createSpinnerControl({
commit,
disabled: isDisabled,
largeStep: props['large-step'],
max: props.max,
min: props.min,
parse: parseValue,
readonly: isReadonly,
step: props.step,
});
// Ref to the sg-input host element
const bitInputRef = ref<HTMLElement>();
// Raw inner <input> extracted from sg-input's shadow root
let inputEl: HTMLInputElement | null = null;
onElement(bitInputRef, (bitInputEl) => {
const rawInput = bitInputEl.shadowRoot?.querySelector<HTMLInputElement>('input') ?? null;
if (!rawInput) return;
inputEl = rawInput;
// Set inputmode and text-align imperatively
rawInput.setAttribute('inputmode', 'decimal');
// Wire change/input events
const handleChange = (e: Event) => {
const val = (e.target as HTMLInputElement).value;
const n = val !== '' ? Number.parseFloat(val) : null;
commit(Number.isNaN(n ?? NaN) ? null : n, e);
};
const handleInput = (e: Event) => {
const val = (e.target as HTMLInputElement).value;
const n = val !== '' ? Number.parseFloat(val) : null;
fieldValue.value = val;
emit('input', { originalEvent: e, value: Number.isNaN(n ?? NaN) ? null : n });
};
rawInput.addEventListener('change', handleChange);
rawInput.addEventListener('input', handleInput);
// Fire user ref callback
props.ref.value?.(rawInput);
const sub = rippleWatch(props.ref, (cb) => {
cb?.(rawInput);
});
return () => {
sub.dispose();
props.ref.value?.(null);
rawInput.removeEventListener('change', handleChange);
rawInput.removeEventListener('input', handleInput);
inputEl = null;
};
});
// Keep the raw input's displayed value in sync with fieldValue signal
watch(() => {
if (!bitInputRef.value) return;
const el = inputEl;
if (el && el.value !== fieldValue.value) el.value = fieldValue.value;
});
bind({
attr: {
size: fCtxProps.size,
value: () => fieldValue.value || null,
variant: fCtxProps.variant,
},
});
const isNonInteractive = computed(() => isDisabled.value || isReadonly.value);
return html`
<div
class="wrapper"
role="spinbutton"
part="control"
:aria-valuenow="${() => parseValue() ?? null}"
:aria-valuemin="${() => props.min.value}"
:aria-valuemax="${() => props.max.value}"
:aria-label="${() => props.label.value}"
:aria-disabled="${() => (isDisabled.value ? 'true' : null)}"
:aria-readonly="${() => (isReadonly.value ? 'true' : null)}"
@keydown="${(e: KeyboardEvent) => spinner.handleKeydown(e)}">
<button
type="button"
part="decrement-btn"
aria-label="Decrease"
?disabled="${() => isNonInteractive.value || spinner.atMin()}"
@click="${(e: Event) => spinner.incrementBy(-(Number(props.step.value) || 1), e)}">
<sg-icon name="minus" size="14" stroke-width="2.5" aria-hidden="true"></sg-icon>
</button>
<sg-input
class="field"
part="field"
ref="${bitInputRef}"
:label="${() => props.label.value ?? ''}"
:label-placement="${() => props['label-placement'].value ?? 'inset'}"
:placeholder="${() => props.placeholder.value ?? ''}"
:name="${() => props.name.value ?? ''}"
:helper="${() => props.helper.value ?? ''}"
:error="${() => props.error.value ?? ''}"
:size="${fCtxProps.size}"
:color="${() => props.color.value ?? ''}"
:variant="${() => props.variant.value ?? ''}"
?rounded="${() => props.rounded.value}"
?disabled="${isDisabled}"
?readonly="${isReadonly}"
?fullwidth="${() => false}">
</sg-input>
<button
type="button"
part="increment-btn"
aria-label="Increase"
?disabled="${() => isNonInteractive.value || spinner.atMax()}"
@click="${(e: Event) => spinner.incrementBy(Number(props.step.value) || 1, e)}">
<sg-icon name="plus" size="14" stroke-width="2.5" aria-hidden="true"></sg-icon>
</button>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [disabledStateMixin, styles],
});Basic Usage
html
<sg-number-input label="Quantity" value="1" min="0" max="100"></sg-number-input>Min / Max / Step
Sizes
Colors
Variants
Disabled & Readonly
Outside Label
Set label-placement="outside" to render the label outside the control box, above the value.
Full Width
Add the fullwidth attribute to stretch the control to its container width.
Handling Change Events
html
<sg-number-input id="qty" label="Quantity" value="1" min="1" max="99"></sg-number-input>
<script type="module">
import '@vielzeug/sigil';
document.getElementById('qty').addEventListener('change', (e) => {
console.log('Value changed to:', e.detail.value);
});
</script>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | number | null | null | Current numeric value |
min | number | — | Minimum allowed value |
max | number | — | Maximum allowed value |
step | number | 1 | Increment / decrement step size |
large-step | number | 10 | Step size for Page Up / Page Down keys |
label | string | — | Visible label text |
label-placement | 'outside' | 'inset' | 'outside' | Label above the control or inset inside it |
name | string | — | Form field name |
placeholder | string | — | Placeholder text when empty |
nullable | boolean | false | Allow an empty / null value |
disabled | boolean | false | Disables the control |
readonly | boolean | false | Prevents user edits |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Focus ring and accent color |
size | 'sm' | 'md' | 'lg' | 'md' | Component size |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'solid' | Visual style variant |
fullwidth | boolean | false | Stretch to the full width of the container |
Events
| Event | Detail | Description |
|---|---|---|
change | { value: number | null } | Fired when value is committed (blur / step) |
input | { value: number | null } | Fired on every keystroke |
CSS Custom Properties
| Property | Description |
|---|---|
--number-input-height | Control height |
--number-input-border-color | Border color |
--number-input-radius | Border radius |
--number-input-bg | Input background |
--number-input-btn-bg | Spin-button background |
--number-input-btn-hover-bg | Spin-button hover background |
Accessibility
The number input component follows WCAG 2.1 Level AA standards.
sg-number-input
↑/↓step the value bystep;Page Up/Page Downstep bylarge-step.Tabmoves focus in and out.
aria-labelledbylinks the label;aria-describedbylinks helper and error text.- Spin buttons use
aria-label("Increment" / "Decrement") andaria-disabledwhen the value is atmin/max. aria-invalidreflects the error state.aria-disabledandaria-readonlyreflect the disabled and readonly states.