registry:ui · Data Entry
number-input — the [- NUM +] stepper
[- NUM +] stepper: 28px square buttons (press physics, font-nav bold characters), centered chromeless input on the Tier-1 .jx-input-lane (placeholder distinction + spinner law live in the jx-pure sheet (Part A)), click + long-press (300ms→100ms) acceleration, min/max clamp, RTL-aware logical properties.
$npx jixoai-ui add number-input
https://ui.jixoai.com/r/number-input.json
overview
Overview
A segmented control, not a text-field fork: two stepper squares around a borderless native number input, with the hold clock, the commit clamp, and the readonly-not-disabled disabled story.
The geometry is one bordered row split three ways: two stepper buttons (text glyphs in
font-nav bold — no icon dependency) around a borderless, centered native <input type="number">. The parts are
full-hit squares — measured 40×40px at the default rung — whose own 1px borders form the
dividers: negative margins overlap the shell border so every line stays exactly 1px. The
native spinners are hidden (appearance: textfield,
the platform stepper law) but native behavior is kept: ↑/↓ step with min/max/step read
straight off the element attributes. DOM order is minus, input, plus and the row is plain
flex — dir="rtl" flips it with zero physical CSS.
The behavior has three measured clocks: a CLICK steps once and clamps into
[min, max]; a HOLD steps immediately, plateaus through a 300ms delay, then
accelerates to one step every 100ms until pointerup ANYWHERE (window-level
listeners — sliding off the button never strands the interval); TYPING commits on
change — empty reverts to undefined, committed rewrites normalize ("007" → "7",
the float-step snap keeps 0.5 steps on 0.5), and out-of-range values clamp. A
disabled field disables the buttons in lockstep while the input turns readonly, not disabled — it stays focusable and
selectable so assistive tech can still read the value; the trade is that a readonly
value still submits, so drop the name when a
disabled field must exit FormData. There is NO formatting or locale layer by contract:
display is String(value), commit is valueAsNumber — locale-raw, the family owns only
stepping.
The axes split by role (all measured in the axes table): density is the whole composite
— the steppers ride the same hit channel as the shell, landing at exactly the WCAG
2.5.8 24px floor at the 2xs rung; the square corner is authored (shape/radius axes
unread); the theme is the form-family split voice (well/ring/hover re-derive under
.dark while the shell atoms stay frozen); elevation is the own control-well (the F-1
fillable-composite ruling); size and color are supply-only with the §1 native-collision
guard. The chrome axis is AMBIENT (the control-integration context resolves frame |
bare — not a component prop): in a bare context the fill and borders fold away for
in-panel compositions (measured: shell background/border and button borders transparent,
well none). Kinship: input and the form families (the same field
scaffold, the same error wiring), input-otp (the
digit-exact shell cousin).
number-input
The [- NUM +] stepper: click steps once and clamps into [min, max], hold accelerates 300ms → 100ms/step, typing commits on change.
<NumberInput
label="workers"
min={1}
max={16}
bind:value
/>types
Types
The integer stepper, the decimal-step stepper, the error state, and the disabled field — plus the RTL posture: geometry from logical properties only.
number-input · variants
max 4 workers per pod
<script lang="ts">
import NumberInput from '@ui/number-input.svelte';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-border p-4"><NumberInput label="workers" value={4} min={1} max={16} /></div>
<div class="border border-border p-4"><NumberInput label="timeout (s)" value={1.5} min={0.5} max={5} step={0.5} /></div>
<div class="border border-border p-4"><NumberInput label="error" value={7} min={1} max={4} error="max 4 workers per pod" /></div>
<div class="border border-border p-4"><NumberInput label="disabled" value={3} min={1} max={8} disabled /></div>
</div>RTL — geometry from logical properties
number-input · rtl
Nothing in the component branches on direction: the stepper is a flex row in DOM order (minus, input, plus). The writing mode does the rest.
<script lang="ts">
import NumberInput from '@ui/number-input.svelte';
let workersRtl = $state(2);
</script>
<div class="grid gap-5 min-[760px]:grid-cols-2">
<div dir="rtl" class="flex flex-col gap-4 border-border border p-4">
<NumberInput label="workers (rtl)" bind:value={workersRtl} min={1} max={16} />
<span class="text-muted-foreground text-[12px]">
dir="rtl" on the wrapper — the [- +] order flips without a physical property in
sight
</span>
</div>
<div class="flex flex-col justify-center gap-2 text-muted-foreground text-[13px] leading-6">
<p class="text-pretty">
Nothing in the component branches on direction: the stepper is a flex row in DOM
order (minus, input, plus). The writing mode does the rest.
</p>
</div>
</div>usage
Usage
Bind the numeric value; min/max/step drive both the buttons and the native input's own stepping.
<!-- click steps once; hold accelerates (300ms → 100ms/step);
typing commits on change and clamps into [min, max] -->
<NumberInput label="workers" bind:value={workers} min={1} max={16} />
<!-- decimal steps snap to the step's precision -->
<NumberInput label="timeout (s)" bind:value={timeout} min={0.5} max={5} step={0.5} />theming
Density and tokens
The shell AND the stepper squares share the density hit channel — the full ladder including the 2xs floor rung (24px, the WCAG 2.5.8 minimum) is rendered live below; the shell's well shadow and the focus ring ride the theme's structural tokens.
| Token | Default | Source |
|---|---|---|
| --jx-hit | 24 / 28 / 32 / 40 / 48px | density |
| --jx-text | 10 / 11 / 12 / 13 / 15px | density |
| --jx-stack | 4 / 4 / 8 / 8px | density |
| --shadow-well | inset 1px 1px 0px hsl(0 0% 0% / 0.12) | structural |
| --ring | var(--primary) | structural |
| --motion-150 | 150ms | structural |
api
API
The generated meta carries 21 entries (20 distinct named props + the synthesized rest — the raw 24 keys include three quoted duplicates); the hand table’s density row folds into the shared universal section beneath (authored 8, served 7 — the fold serves the generic §4 vocabulary; the family’s measurements live in the axes table). Everything else (name, placeholder, autocomplete…) rides through restProps onto the native input.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
value bind | number | — | Committed quantity; undefined renders empty. |
| min | number | — | Lower bound; stepping and the change-commit clamp into it. |
| max | number | — | Upper bound; stepping and the change-commit clamp into it. |
| step | number | 1 | Step increment; also the native input’s step attribute. Decimal steps snap to the step’s precision (0.5 steps land on 0.5). |
| label | string | — | Field label rendered as label[for] above the control. |
| error | string | — | Error text: sets aria-invalid=true on the input and wires aria-describedby to the "! message" line (announced via the described-by relation, not the visible text alone). |
| disabled | boolean | false | Disables the buttons in lockstep; the input turns readonly, not disabled — focusable and readable for AT. A readonly value still submits: drop the name when it must exit FormData. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on number-input
Density is the one consumed ladder — and the composite makes it visible: the stepper squares ride the same hit channel as the shell, from the 48px lg rung down to the 24px 2xs floor (the WCAG 2.5.8 minimum). The square corner is authored (shape/radius unread); the theme is the form-family split voice (well/ring/hover re-derive, shell atoms frozen); elevation is the own control-well; size and color supply with the §1 native-collision guard; motion rides the sheet constants plus the measured hold clock.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | CONSUMED — the WHOLE composite is the ladder: shell min-height, the steppers' width AND height (full-hit squares — measured 40×40px at the default rung, 24×24px at 2xs) and the voice (--jx-text 13px, 10px at 2xs) all ride the kernel channels. Ambient (the field's data-density attr renders null, measured) the channels cascade from the ancestor [data-density] scope; an explicit lane stamps the rung attr on the field root and the global scope selector re-scopes in place (measured data-density=2xs → 24px, restored 40px on removal). THE FLOOR: the 2xs rung lands the steppers at exactly 24px — the WCAG 2.5.8 target-size minimum. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY, §1 COLLISION GUARDED — the axis never reaches the native element as a size attribute (stripped from the rest spread; the §1 native collision rule) and the family has ZERO readers of --jx-size-effective (grep receipt): the field's voices are the density kernel's px channels. The §11 stamp moves the field root's font-size only. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | OWN SQUARE, AXIS UNREAD — the segmented-control row is AUTHORED corner-square (borderRadius: none in the shell atoms; measured 0px at the ambient lane) — the square row is the family's identity. Zero shape-channel readers (grep receipt). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | OWN SQUARE, AXIS UNREAD — same authored corner (measured borderRadius 0px); zero radius-channel readers (grep receipt). An explicit lane stamps the carriers for composed descendants only. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY, §1 COLLISION GUARDED — the hue axis never reaches the native element (§1) and the family paints the neutral surface + ink voices with zero color-channel readers (grep receipt). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE SPLIT VOICE (form-family law, measured per element) — explicit dark rides the .dark class bridge on the field root. RE-DERIVING legacy chains: the well shadow (--shadow-well, black ink → white ink), the focus ring (--ring → the drifted dark primary) and the stepper hover (--muted 0.9551 → 0.2178) all flip. FROZEN :root atom snapshots (stylex defineVars, no tokenScope stamp): the shell background stayed oklch(1 0 0) while --background flipped to oklch(0 0 0); the shell border likewise — but NOT the cell ink: the cell atom carries no color; the ink rides the standard .jx-html-control-lane and re-derives dark on the frozen white shell — the dark field's value is 1:1-contrast INVISIBLE (the honest cost this split carries; W-next #7's first hard legibility failure, measured with a screenshot receipt). system/auto = tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | OWN WELL, AXIS UNREAD — the shell carries the control-well inset (--shadow-well, the fillable-composite F-1 ruling: hover deepens to --shadow-well-hover, intensity not tier); --jx-elevation-effective has zero readers (grep receipt). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SHEET CONSTANTS + A JS CLOCK, AXIS UNREAD — the pose transitions ride --motion-150/--motion-ease-out with the prefers-reduced-motion kill; --jx-motion-effective has zero readers (grep receipt). The hold accelerator is a JS clock, measured receipt: first step ON pointerdown, plateau through the 300ms delay, then 100ms/step steady-state (steps at ~420/700/1000ms in the probe), and a window-level pointerup ends the run from anywhere. Number unit: coefficient. |
Receipts: the density ladder (40×40px steppers at default; 24×24px + 10px voice at the 2xs stamp, restored on removal), the hold clock (immediate step → 300ms plateau → 100ms/step: values 4→5→7→10→13 at ~80/420/700/1000ms in the probe), the commit clamp ("007" → 7, 99 → 16 under max=16, empty → undefined), the theme split (--background flipped to oklch(0 0 0) while the shell stayed oklch(1 0 0); --shadow-well/--ring/--muted all flipped) and the bare-chrome strip were measured on this page's served DOM (probe, task 32); the unread/supply-only rows carry grep receipts over ui/number-input/. The error state's shell renders the dashed DESTRUCTIVE border through the family's unlayered attribute carve-out (border-style dashed + --jx-destructive — the atom-vs-atom cascade kill, fixed family-side after task 32 flagged it; re-measured dashed here), and the error WIRING — aria-invalid + described-by — is fully live. The query() seat below rides the md viewport key (48rem) on the consumed density lane.
<script lang="ts">
import NumberInput from '@ui/number-input.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (small) applies; at 48rem+ the md case (large)
wins — 32px steppers become 48px, the whole kernel ladder re-rungs -->
<NumberInput label="responsive" density={query({ md: 'large' }, 'small')} />number-input · query()
Media keys are min-width: below 48rem the base (small — 32px steppers, 12px voice) applies; at 48rem and wider the md case wins (large — 48px steppers, 15px voice). The string lane takes both generics. Resize across 48rem.
<script lang="ts">
import NumberInput from '@ui/number-input.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (small) applies; at 48rem+ the md case (large)
wins — 32px steppers become 48px, the whole kernel ladder re-rungs -->
<NumberInput label="responsive" density={query({ md: 'large' }, 'small')} />number-input · universal props
<NumberInput label="floor rung" density="2xs" />
<NumberInput label="dark field" theme="dark" />a11y
Accessibility
The inner control is a real type=number input (implicit spinbutton semantics); the stepper squares ride the hit-channel floor; disabled keeps the value readable for assistive tech; the error is announced through the described-by relation.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the centered number input (the field’s single stop) |
| ↑ / ↓ | Steps by the step increment, clamped into [min, max] — native input behavior, kept |
| Enter / blur | Commits typed text; empty reverts to undefined, values clamp into range |
| hold − / + | Steps once on press, accelerates after 300ms to one step every 100ms; releasing ANYWHERE ends the run (window-level pointerup) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| spinbutton (implicit) | input[type=number] | The native element carries the spinbutton role with value/min/max semantics — no ARIA needed |
| aria-label | "decrease" / "increase" | On the two stepper buttons (type="button"). Hardcoded English — a localization gap to know when translating |
| aria-invalid | 'true' | On the input when the error prop is provided |
| aria-describedby | {id}-error | Points at the "! message" validation line — the error is announced through the relation (the "!" mark is aria-hidden) |
| readonly (disabled) | attribute | Disabled turns the input readonly, not disabled — the value stays focusable and readable |