Primitives Forms
Number field
A native number input with two styled step buttons. The input keeps the value, the arrow keys, step, min, max and validation; the buttons call the platform's own stepUp() and stepDown(). Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1082 B brotli · 1228 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<input type="number">,stepUp(),stepDown(),@media (scripting),:has()- Shared listeners
- click
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<input type="number">, stepUp()/stepDown()
On this page
Example
<label for="number-field-seats">Seats</label>
<div data-slean="number-field">
<button type="button" tabindex="-1" aria-label="Decrease" data-slean-part="decrement"></button>
<input id="number-field-seats" type="number" name="seats" min="1" max="20" value="4"
data-slean-part="input" />
<button type="button" tabindex="-1" aria-label="Increase" data-slean-part="increment"></button>
</div><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="number-field".
import '@svelte-lean/styles/number-field.css';
let seats = $state(4);
</script>
<label for="seats">Seats</label>
<div data-slean="number-field">
<button type="button" tabindex="-1" aria-label="Decrease" data-slean-part="decrement"></button>
<input id="seats" type="number" name="seats" min="1" max="20" bind:value={seats}
data-slean-part="input" />
<button type="button" tabindex="-1" aria-label="Increase" data-slean-part="increment"></button>
</div>
<p>{seats} seats</p>Why this implementation exists
<input type="number"> already parses, validates, steps with the arrow keys and submits. What it does not do well is its spin buttons: small, different in every engine, impossible to style and awkward on touch.
The number field replaces them with two ordinary buttons and one shared click listener that calls stepUp() or stepDown(), then dispatches input and change as a user edit would. Because the platform performs the step, step, min, max and the snapping to the step base are the input’s own rules, not a reimplementation.
The browser owns
- typing, ArrowUp and ArrowDown, step, min and max, validation and the form value
- stepUp() and stepDown(), which the buttons call, so every rule of the input applies
- the spin button role and its value for assistive technology
Svelte Lean owns
- two styled buttons that step the input and dispatch input and change
- refusing a disabled or read-only input and step="any"
- number-field.css: the box, the minus and plus, the focus ring on the root; native spin buttons without JavaScript
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="number-field"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/number-field/register';import '@svelte-lean/styles/number-field.css';Put the input and the two buttons in a root; label the input with <label for>. Give the buttons tabindex="-1" and an aria-label.
Bind the input with bind:value; a click on a button reaches the binding through the dispatched input event.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div data-slean="number-field"> | – | yes | Draws the box and the focus ring. No options: min, max and step are the input’s. |
| input | <input type="number" id="…"> | input | yes | Labelled with <label for>. |
| decrement | <button type="button" tabindex="-1" aria-label="Decrease"> | decrement | no | Out of the tab order: ArrowDown does the same. |
| increment | <button type="button" tabindex="-1" aria-label="Increase"> | increment | no | Out of the tab order: ArrowUp does the same. |
Runtime profile
The number field registers one click handler with the shared router. A thousand fields keep one listener (tests/number-field.test.ts).
Accessibility contract
- The input is a native spin button, named by its label and announced with its value.
- The buttons are out of the tab order, as in the WAI-ARIA spinbutton pattern: ArrowUp and ArrowDown do what they do.
- At
minormaxthe platform refuses the step and nothing is dispatched. - The focus ring is drawn around the whole field while the input has keyboard focus.
Keyboard
| Key | When | Result |
|---|---|---|
| ArrowUp/ArrowDown | focus in the input | Steps up or down (native) |
| Tab | in the page | Reaches the input only |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
stepUp(), stepDown() | Widely available | Not applicable |
@media (scripting) | Baseline 2023 | The buttons show without JavaScript and do nothing |
Without JavaScript
The input works with its native spin buttons and keys. The stylesheet hides the two buttons under @media (scripting: none), so the page offers only what works.
Server rendering
Render the input with its value. Nothing is written by the behavior before a click.
Before hydration
Before the behavior loads, the buttons do nothing; typing and the arrow keys work.
Styling
number-field.css draws one box around the buttons and the input, a minus and a plus with pseudo-elements, the focus ring on the box through :has(), the invalid border from :user-invalid, and hides the native spin buttons only while scripting is enabled. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-control-height-md | 2.25rem | min-block-size, inline-size |
--slean-control-border | var(--slean-border-strong) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-control-bg | var(--slean-surface) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-color-scheme | light | color-scheme |
--slean-text-sm | 0.875rem | font-size |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-space-2 | 0.5rem | padding-inline |
--slean-control-icon | var(--slean-fg-muted) | color |
--slean-icon-minus | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-icon-plus | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5v9M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E") | mask-image |
--slean-control-border-hover | var(--slean-accent) | border-color |
--slean-muted | var(--slean-neutral-3) | background |
--slean-control-border-focus | var(--slean-accent) | border-color |
--slean-control-ring | 0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent) | box-shadow |
--slean-warning | oklch(76% 0.16 80) | border-color |
--slean-control-ring-warning | 0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent) | box-shadow |
--slean-danger | oklch(55% 0.2 25) | border-color |
--slean-control-ring-danger | 0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent) | box-shadow |
--slean-control-border-disabled | var(--slean-border) | border-color |
--slean-control-bg-disabled | var(--slean-muted) | background |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-text-md | 1rem | font-size |
State selectors the stylesheet targets, all from the platform or ARIA: ::-webkit-inner-spin-button, :disabled, :focus-visible, :hover, :user-invalid, [aria-invalid="true"].
Variant attributes: data-status (warning, error).
Compatibility notes
stepUp() and stepDown() are widely available. step="any" cannot be stepped; the buttons then do nothing. Press-and-hold repeat and number formatting are not part of the primitive.
Testing
packages/primitives/tests/number-field.test.tsVitest: stepping within bounds, events, disabled and read-only, step="any", 1000 rootsapps/playground/tests/primitives/widgets.spec.tsPlaywright: tooltip, toast, tree, range slider and number field in Chrome, with axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/number-field/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/number-field/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/number-field/behavior.tsthe behavior definitionpackages/primitives/src/number-field/register.tsthe registration modulepackages/styles/css/number-field.cssthe optional stylesheet