jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — number-input

$npx jixoai-ui add number-input

https://ui.jixoai.com/r/number-input.json

click / hold / type min / max / step clamp hold acceleration 300ms → 100ms steppers ride the hit floor rtl: logical properties only

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.

number-input-usage.svelte svelte
<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

number-input-types-demo.svelte svelte
<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

dir="rtl" on the wrapper — the [- +] order flips without a physical property in sight

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.

number-input-rtl-demo.svelte svelte
<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.

NumberInput usage
<!-- 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.

2xs
xs
sm
default
lg
TokenDefaultSource
--jx-hit24 / 28 / 32 / 40 / 48pxdensity
--jx-text10 / 11 / 12 / 13 / 15pxdensity
--jx-stack4 / 4 / 8 / 8pxdensity
--shadow-wellinset 1px 1px 0px hsl(0 0% 0% / 0.12)structural
--ringvar(--primary)structural
--motion-150150msstructural

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

PropertyTypeDefaultDescription
value bindnumber—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 number1Step 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 booleanfalseDisables 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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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.

PropertyTypeDefaultDescription
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.

one real query() case
<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.

number-input-query-demo.svelte svelte
<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

number-input-universal.svelte svelte
<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

KeyAction
TabMoves 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 / blurCommits 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

AttributeValueDescription
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}-errorPoints at the "! message" validation line — the error is announced through the relation (the "!" mark is aria-hidden)
readonly (disabled)attributeDisabled turns the input readonly, not disabled — the value stays focusable and readable
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h