Skip to main content
Search
Search the documentation

Number input

A number field with steppers, bounds that hold, and arithmetic that stays exact.

pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-number-input.json

A hand-rolled stepper field clamps in one direction and forgets the other: the buttons stop at the cap while a typed 150 walks straight through it, so the same field enforces its bounds with the mouse and not with the keyboard. Dropping the text field entirely (just - / value / +) closes that hole by taking typing away, which turns a jump from 1 to 40 into thirty-nine presses.

Seats on the plan: 1

What Coral adds

  • The bounds hold both ways. Steppers stop at the edge and a typed value clamps on commit.
  • The field never lies. Clamping 150 to 10 also rewrites what is on screen, so the number you read is the number that is stored.
  • The arithmetic stays exact. Stepping by 0.1 gives 0.3, not 0.30000000000000004.
  • Scrolling does not edit. A focused type="number" steps on wheel in Chromium; a page scrolled past one would change silently.
  • Empty means empty. Clearing the field gives undefined, not the minimum.

Everything else is the primitive's: input-group puts the steppers inside the field, so it reads as one control with one focus ring instead of three.

Bounds

Replicas, between 0 and 25 · value: 20

Type 999 and blur the field.

Type 999 into that field and click away. It becomes 25, and the field itself updates: the state and what you can see never disagree.

Clamping happens on commit (blur, Enter, an arrow-key step), not on every keystroke. Clamping per keystroke fights the person typing: with a max of 100 the 1 of 150 is fine and the 15 is fine, and only the finished number is wrong.

A stepper disables itself once the value is against its bound. Leave min or max out and that direction is unbounded, negatives included. Defaulting min to 0 is the tempting shortcut, and it quietly makes a temperature or a balance field impossible to express.

Decimals

sample rate · step 0.1 · 0.1

budget · step 1, decimals 2 · 1000

step sets the precision as well as the jump, so step={0.1} rounds to one decimal. When the two genuinely differ (money that steps by whole units but stores cents), set decimals on its own.

Empty, readonly, disabled

empty, min 5 · undefined

readonly

disabled

An empty field keeps both steppers live, because stepping from empty has somewhere to go either way. The first press lands on the first allowed value: with min={5}, + gives 5, not 6.

Import

<script lang="ts">
	import NumberInput from '#lib/components/coral/kit/number-input/number-input.svelte';
</script>

Props

Everything the shadcn input accepts stays available: placeholder, disabled, readonly, name, id, aria-*, ref. On top of that:

PropTypeDefaultDescription
valuenumber-Bindable. undefined means empty.
minnumber-Lowest allowed. Omitted means unbounded, negatives included.
maxnumber-Highest allowed. Omitted means unbounded.
stepnumber1Jump per press. Also sets the rounding precision.
decimalsnumberfrom stepRounding precision, when it differs from the step.
onchange(value: number | undefined) => void-Fired on a stepper or a committed edit. Never on mount.
decrementLabelstringDecreaseAccessible label for the − button.
incrementLabelstringIncreaseAccessible label for the + button.
classstring-Merged onto the input - e.g. its width.
groupClassstring-Merged onto the bordered group around everything.

Accessibility

The field stays a native input type="number", so it keeps what the browser already gives: arrow keys step, mobile shows a numeric keypad, and assistive tech reads it as a spinbutton with its min, max and step. The two stepper buttons carry labels and are type="button", so neither submits a surrounding form.

The native spinner arrows are hidden, because the component draws its own and two sets of steppers on one field is one too many.

One thing to know about locale. type="number" parses through the browser, which in a Spanish locale may accept 1,5 and in another only 1.5. If a field must accept a decimal comma everywhere, that is a text field with its own parsing, not this component.

lib/number

The arithmetic lives in lib/number.ts, shared with scrub input so a value moved by either field lands on the same number. It is exported on its own, so the same clamping and rounding can be reused where there is no input at all: validating a payload, totalling a column.

import { clamp, decimalsOf, round, stepValue } from '#lib/components/coral/lib/number.js';

decimalsOf(0.05); // 2
round(0.1 + 0.2, 1); // 0.3
clamp(150, 0, 25); // 25
stepValue({ value: undefined, delta: 1, min: 5, decimals: 0 }); // 5

They live in lib/number.ts rather than beside the component because scrub input steps its value the same way, and the two cannot be allowed to drift. Both compose them through lib/number-field.ts, which also holds the commit, the typed-value read and the wheel handling.