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.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-number-input.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-number-input.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-number-input.jsonA 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
<script lang="ts">
import NumberInput from '#lib/components/coral/kit/number-input/number-input.svelte';
let seats = $state(1);
</script>
<div class="flex flex-col items-center gap-3">
<NumberInput bind:value={seats} min={1} max={99} />
<p class="text-sm text-muted-foreground">Seats on the plan: {seats ?? '—'}</p>
</div>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
150to10also rewrites what is on screen, so the number you read is the number that is stored. - The arithmetic stays exact. Stepping by
0.1gives0.3, not0.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.
<script lang="ts">
import NumberInput from '#lib/components/coral/kit/number-input/number-input.svelte';
let replicas = $state(20);
</script>
<div class="flex flex-col items-center gap-3">
<!-- Try typing 999, or -5, then click away. -->
<NumberInput bind:value={replicas} min={0} max={25} />
<p class="text-sm text-muted-foreground">
Replicas, between 0 and 25 · value: {replicas ?? 'empty'}
</p>
<p class="text-sm text-muted-foreground">Type 999 and blur the field.</p>
</div>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
<script lang="ts">
import NumberInput from '#lib/components/coral/kit/number-input/number-input.svelte';
let sampleRate = $state(0.1);
let budget = $state(1000);
</script>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col items-center gap-2">
<NumberInput bind:value={sampleRate} step={0.1} min={0} max={1} class="w-20" />
<p class="text-sm text-muted-foreground">sample rate · step 0.1 · {sampleRate ?? '—'}</p>
</div>
<div class="flex flex-col items-center gap-2">
<!-- Steps by whole units but still stores cents, which is what a billing cap needs. -->
<NumberInput bind:value={budget} step={1} decimals={2} min={0} class="w-24" />
<p class="text-sm text-muted-foreground">budget · step 1, decimals 2 · {budget ?? '—'}</p>
</div>
</div>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
<script lang="ts">
import NumberInput from '#lib/components/coral/kit/number-input/number-input.svelte';
let empty = $state<number>();
let fixed = $state(8);
let off = $state(3);
</script>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col items-center gap-2">
<NumberInput bind:value={empty} min={5} max={9} placeholder="—" />
<p class="text-sm text-muted-foreground">empty, min 5 · {empty ?? 'undefined'}</p>
</div>
<div class="flex flex-col items-center gap-2">
<NumberInput bind:value={fixed} readonly />
<p class="text-sm text-muted-foreground">readonly</p>
</div>
<div class="flex flex-col items-center gap-2">
<NumberInput bind:value={off} disabled />
<p class="text-sm text-muted-foreground">disabled</p>
</div>
</div>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:
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | - | Bindable. undefined means empty. |
min | number | - | Lowest allowed. Omitted means unbounded, negatives included. |
max | number | - | Highest allowed. Omitted means unbounded. |
step | number | 1 | Jump per press. Also sets the rounding precision. |
decimals | number | from step | Rounding precision, when it differs from the step. |
onchange | (value: number | undefined) => void | - | Fired on a stepper or a committed edit. Never on mount. |
decrementLabel | string | Decrease | Accessible label for the − button. |
incrementLabel | string | Increase | Accessible label for the + button. |
class | string | - | Merged onto the input - e.g. its width. |
groupClass | string | - | 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 accept1,5and in another only1.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 }); // 5They 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.