sveltelean Primitives
Versionv0.2.0 GitHub

Example

Two number fields
<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>
The buttons honor step, min and max because the platform does the stepping. The second field steps by 0.5. With the input focused, ArrowUp and ArrowDown do the same.

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/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/number-field/register';
stylesheets
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

PartElementdata-slean-partRequiredNotes
root<div data-slean="number-field">–yesDraws the box and the focus ring. No options: min, max and step are the input’s.
input<input type="number" id="…">inputyesLabelled with <label for>.
decrement<button type="button" tabindex="-1" aria-label="Decrease">decrementnoOut of the tab order: ArrowDown does the same.
increment<button type="button" tabindex="-1" aria-label="Increase">incrementnoOut 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 min or max the platform refuses the step and nothing is dispatched.
  • The focus ring is drawn around the whole field while the input has keyboard focus.

Keyboard

KeyWhenResult
ArrowUp/ArrowDownfocus in the inputSteps up or down (native)
Tabin the pageReaches the input only

Platform features

FeatureBaselineOutside the target
stepUp(), stepDown()Widely availableNot applicable
@media (scripting)Baseline 2023The 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:

TokenDefault (light)Applies to
--slean-control-height-md2.25remmin-block-size, inline-size
--slean-control-bordervar(--slean-border-strong)border
--slean-radius-md0.625remborder-radius
--slean-control-bgvar(--slean-surface)background
--slean-fgvar(--slean-neutral-12)color
--slean-color-schemelightcolor-scheme
--slean-text-sm0.875remfont-size
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-space-20.5rempadding-inline
--slean-control-iconvar(--slean-fg-muted)color
--slean-icon-minusurl("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-plusurl("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-hovervar(--slean-accent)border-color
--slean-mutedvar(--slean-neutral-3)background
--slean-control-border-focusvar(--slean-accent)border-color
--slean-control-ring0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent)box-shadow
--slean-warningoklch(76% 0.16 80)border-color
--slean-control-ring-warning0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent)box-shadow
--slean-dangeroklch(55% 0.2 25)border-color
--slean-control-ring-danger0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent)box-shadow
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-md1remfont-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

Source