Svelte 5 component · fractalsvelte-ui · Forms
NumberInput
A numeric stepper control equipped with incremental and decremental action buttons. Clamps values within minimum and maximum limits, adjusts by custom step increments, and binds numeric value.
Install
NumberInput is a Svelte 5 number input component in @fractaldesign/fractalsvelte-ui. It works in SvelteKit and in any Svelte 5 app.
pnpm add @fractaldesign/fractalsvelte-uiImport
import { NumberInput } from '@fractaldesign/fractalsvelte-ui';Usage
<NumberInput value={0} />Props
Also accepts every attribute of HTMLInputAttributes (without size), spread onto
the root element.
| Name | Type | Default |
|---|---|---|
value bindable | number | 0 |
min | number | — |
max | number | — |
step | number | 1 |
label | string | — |
helperText | string | — |
error | boolean | string | — |
disabled | boolean | false |
readonly | boolean | false |
controls | boolean | true |
size | Size 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
id | string | — |
class | string | '' |
Source
Show the NumberInput.svelte source
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { Size } from '../../types';
interface Props extends Omit<HTMLInputAttributes, 'size'> {
value?: number;
min?: number;
max?: number;
step?: number;
label?: string;
helperText?: string;
error?: boolean | string;
disabled?: boolean;
readonly?: boolean;
controls?: boolean;
size?: Size;
id?: string;
class?: string;
}
let {
value = $bindable(0),
min,
max,
step = 1,
label,
helperText,
error,
disabled = false,
readonly = false,
controls = true,
size = 'md',
id,
class: className = '',
...restProps
}: Props = $props();
let inputEl: HTMLInputElement | null = $state(null);
let timer: any = null;
const autoId = 'f-num-' + Math.random().toString(36).slice(2, 9);
const inputId = $derived(id || autoId);
const hasError = $derived(Boolean(error));
const errorMessage = $derived(typeof error === 'string' ? error : undefined);
const canDecrement = $derived(
!disabled && !readonly && (min === undefined || (value ?? 0) > min)
);
const canIncrement = $derived(
!disabled && !readonly && (max === undefined || (value ?? 0) < max)
);
function getDecimals(num: number): number {
const str = num.toString();
return str.includes('.') ? str.split('.')[1].length : 0;
}
function clamp(val: number): number {
let res = val;
if (min !== undefined && res < min) res = min;
if (max !== undefined && res > max) res = max;
const decimals = getDecimals(step);
return Number(res.toFixed(decimals));
}
function stepBy(amount: number) {
if (disabled || readonly) return;
const current = value ?? 0;
value = clamp(current + amount);
}
function handleInputChange(e: Event) {
const target = e.target as HTMLInputElement;
const num = parseFloat(target.value);
if (isNaN(num)) {
value = min ?? 0;
} else {
value = clamp(num);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (disabled || readonly) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
stepBy(step);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
stepBy(-step);
}
}
function startStepping(amount: number) {
stepBy(amount);
timer = setInterval(() => {
stepBy(amount);
}, 100);
}
function stopStepping() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
</script>
<div
class="f-number-field {className}"
data-size={size}
data-disabled={disabled || undefined}
data-invalid={hasError || undefined}
>
{#if label}
<label for={inputId} class="f-number-label">
{label}
</label>
{/if}
<div class="f-number-wrapper">
{#if controls}
<button
type="button"
class="f-number-btn f-number-btn-dec"
disabled={!canDecrement}
tabindex={-1}
aria-label="Decrease value"
onmousedown={() => startStepping(-step)}
onmouseup={stopStepping}
onmouseleave={stopStepping}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
{/if}
<input
bind:this={inputEl}
id={inputId}
type="number"
value={value}
{min}
{max}
{step}
{disabled}
{readonly}
class="f-number-input"
onchange={handleInputChange}
onkeydown={handleKeyDown}
aria-invalid={hasError || undefined}
aria-describedby={errorMessage ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
{...restProps}
/>
{#if controls}
<button
type="button"
class="f-number-btn f-number-btn-inc"
disabled={!canIncrement}
tabindex={-1}
aria-label="Increase value"
onmousedown={() => startStepping(step)}
onmouseup={stopStepping}
onmouseleave={stopStepping}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
{/if}
</div>
{#if errorMessage}
<p id="{inputId}-error" class="f-number-message f-number-error-msg" role="alert">
{errorMessage}
</p>
{:else if helperText}
<p id="{inputId}-helper" class="f-number-message f-number-helper-msg">
{helperText}
</p>
{/if}
</div>
<style>
@layer fractalsvelte-ui.components {
.f-number-field {
display: flex;
flex-direction: column;
gap: var(--f-space-2xs);
width: 100%;
font-family: inherit;
box-sizing: border-box;
}
.f-number-label {
font-size: var(--f-text-sm);
font-weight: 500;
color: var(--f-text);
user-select: none;
}
.f-number-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--f-input);
border: 1px solid var(--f-border);
border-radius: var(--f-radius-md);
transition: border-color var(--f-duration-fast) var(--f-ease),
box-shadow var(--f-duration-fast) var(--f-ease);
box-sizing: border-box;
overflow: hidden;
}
.f-number-wrapper:hover:not([data-disabled='true']) {
border-color: var(--f-border-strong);
}
.f-number-wrapper:focus-within {
border-color: var(--f-primary);
box-shadow: 0 0 0 3px var(--f-ring);
}
.f-number-input {
flex: 1;
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--f-text);
font-family: var(--f-font-mono);
font-size: var(--f-text-base);
font-weight: 500;
text-align: center;
padding: 0 var(--f-space-sm);
box-sizing: border-box;
appearance: textfield;
-moz-appearance: textfield;
}
.f-number-input::-webkit-outer-spin-button,
.f-number-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.f-number-input:disabled {
cursor: not-allowed;
}
.f-number-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--f-sunken);
border: none;
color: var(--f-text);
cursor: pointer;
user-select: none;
transition: background var(--f-duration-fast) var(--f-ease),
color var(--f-duration-fast) var(--f-ease);
flex-shrink: 0;
}
.f-number-btn:hover:not(:disabled) {
background: var(--f-hover);
color: var(--f-primary);
}
.f-number-btn:active:not(:disabled) {
background: var(--f-active);
}
.f-number-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.f-number-btn-dec {
border-right: 1px solid var(--f-border);
}
.f-number-btn-inc {
border-left: 1px solid var(--f-border);
}
/* Sizes */
.f-number-field[data-size='sm'] .f-number-wrapper {
min-height: 32px;
border-radius: var(--f-radius-sm);
}
.f-number-field[data-size='sm'] .f-number-input {
font-size: var(--f-text-sm);
}
.f-number-field[data-size='sm'] .f-number-btn {
width: 30px;
height: 30px;
}
.f-number-field[data-size='md'] .f-number-wrapper {
min-height: 40px;
border-radius: var(--f-radius-md);
}
.f-number-field[data-size='md'] .f-number-input {
font-size: var(--f-text-base);
}
.f-number-field[data-size='md'] .f-number-btn {
width: 38px;
height: 38px;
}
.f-number-field[data-size='lg'] .f-number-wrapper {
min-height: 48px;
border-radius: var(--f-radius-lg);
}
.f-number-field[data-size='lg'] .f-number-input {
font-size: var(--f-text-lg);
}
.f-number-field[data-size='lg'] .f-number-btn {
width: 46px;
height: 46px;
}
/* Invalid state */
.f-number-field[data-invalid='true'] .f-number-wrapper {
border-color: var(--f-danger);
}
.f-number-field[data-invalid='true'] .f-number-wrapper:focus-within {
box-shadow: 0 0 0 3px var(--f-danger-bg);
}
/* Disabled */
.f-number-field[data-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}
.f-number-field[data-disabled='true'] .f-number-wrapper {
background: var(--f-sunken);
cursor: not-allowed;
}
.f-number-message {
margin: 0;
font-size: var(--f-text-xs);
line-height: 1.4;
}
.f-number-helper-msg {
color: var(--f-text-muted);
}
.f-number-error-msg {
color: var(--f-danger-text);
font-weight: 500;
}
}
</style>Related components
PinInput A segmented one-time passcode and verification input supporting numeric or text entry.Radio An individual selection control featuring a spring-animated inner indicator dot.RadioGroup A semantic radiogroup container that coordinates single-option selection among child radio items.RangeSlider A dual-thumb slider control designed for selecting lower and upper numeric boundaries.RotaryDial A precision hardware-grade rotational dial and knob control with 270-degree arc tracking, circular tick…SearchInput A specialized search field pre-configured with search iconography, quick clear action, and keyboard shortcut…