Skip to content

cv-number

Numeric input field with ARIA spinbutton semantics, optional stepper controls, clearable behavior, and prefix/suffix slots.

Headless: createNumber

Usage

View source
html
<div class="number-demo-shell" data-demo="number" data-live-demo-height="900">
  <section class="number-demo-hero" aria-labelledby="number-demo-title">
    <div class="number-demo-copy">
      <span class="number-demo-kicker">Numeric spinbutton primitive</span>
      <h3 id="number-demo-title">
        Clamp, step, clear, and serialize numeric state through one field surface.
      </h3>
      <p>
        The headless number model owns draft text, bounds, step math, clear defaults, focus state, and ARIA
        spinbutton attributes. UIKit renders the editable surface, slots, stepper controls, sizes, and form
        integration.
      </p>
    </div>

    <dl class="number-demo-metrics" aria-label="Number contract summary">
      <div>
        <dt>Range</dt>
        <dd>min / max / step / large-step</dd>
      </div>
      <div>
        <dt>Actions</dt>
        <dd>arrows / page keys / stepper / clear</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>draft / focused / filled / invalid</dd>
      </div>
    </dl>
  </section>

  <section class="number-demo-board" aria-label="Number examples in a vault policy form">
    <form class="number-demo-form" data-number-form>
      <div class="number-demo-form-head">
        <div>
          <span>Visible policy limits</span>
          <strong>decoy-profile.local</strong>
        </div>
        <cv-badge variant="primary" pill>bounded input</cv-badge>
      </div>

      <div class="number-demo-field-grid">
        <cv-field required>
          <span slot="label">Decoy quota</span>
          <cv-number
            data-number-primary
            name="quota"
            value="30"
            default-value="30"
            min="0"
            max="100"
            step="5"
            large-step="25"
            clearable
            stepper
          >
            <span slot="suffix">GB</span>
          </cv-number>
          <span slot="description">Arrow keys step by 5. Page keys step by 25. Clear returns to 30.</span>
        </cv-field>

        <cv-field>
          <span slot="label">HOTP counter</span>
          <cv-number name="counter" value="12" min="0" max="999999" step="1" stepper clearable>
            <span slot="prefix">#</span>
          </cv-number>
          <span slot="description"
            >Stepper controls expose the same committed change event as keyboard steps.</span
          >
        </cv-field>

        <cv-field>
          <span slot="label">Retry window</span>
          <cv-number name="retry" variant="filled" value="3" min="1" max="10" step="1" clearable>
            <span slot="suffix">tries</span>
          </cv-number>
          <span slot="description">Filled variant keeps the same headless range and form contract.</span>
        </cv-field>

        <cv-field>
          <span slot="label">Timeout</span>
          <cv-number name="timeout" value="45" min="5" max="120" step="5" large-step="30">
            <span slot="suffix">sec</span>
          </cv-number>
          <span slot="description">Draft edits commit on Enter or blur, then snap through the model.</span>
        </cv-field>
      </div>

      <div class="number-demo-actions" aria-label="Programmatic number actions">
        <cv-button type="button" size="small" data-number-action="step-up">Step up</cv-button>
        <cv-button type="button" size="small" data-number-action="page-up">Page up</cv-button>
        <cv-button type="button" size="small" variant="secondary" data-number-action="reset">Reset</cv-button>
        <cv-button type="button" size="small" variant="secondary" data-number-action="validate"
          >Validate</cv-button
        >
      </div>
    </form>

    <aside class="number-demo-side" aria-label="Number event output">
      <div class="number-demo-side-head">
        <span class="number-demo-kicker">Event stream</span>
        <h4>Committed numeric changes report a stable number value, not draft text.</h4>
      </div>

      <p class="number-demo-log" role="status" aria-live="polite" data-number-output>
        Waiting for a committed number event.
      </p>

      <dl class="number-demo-live" aria-label="Live number state">
        <div>
          <dt>Primary value</dt>
          <dd data-number-mirror>30 GB</dd>
        </div>
        <div>
          <dt>Last event</dt>
          <dd data-number-active>none</dd>
        </div>
        <div>
          <dt>Range</dt>
          <dd>0 to 100</dd>
        </div>
      </dl>
    </aside>
  </section>

  <section class="number-demo-section" aria-labelledby="number-demo-matrix-title">
    <div class="number-demo-section-header">
      <span class="number-demo-kicker">Variants, bounds, and field states</span>
      <h4 id="number-demo-matrix-title">
        Use one number contract, then tune density, affordance, or validation through attributes.
      </h4>
    </div>

    <div class="number-demo-matrix" aria-label="Number state matrix">
      <div>
        <span>Range edges</span>
        <cv-number value="0" min="0" max="10" step="1" stepper></cv-number>
        <cv-number value="10" min="0" max="10" step="1" stepper></cv-number>
      </div>

      <div>
        <span>Size</span>
        <cv-number size="small" value="8" min="0" max="16"></cv-number>
        <cv-number value="16" min="0" max="32"></cv-number>
        <cv-number size="large" value="32" min="0" max="64"></cv-number>
      </div>

      <div>
        <span>Affixes</span>
        <cv-number value="19" clearable>
          <span slot="prefix">$</span>
          <span slot="suffix">.00</span>
        </cv-number>
        <cv-number value="128" variant="filled" min="16" max="512">
          <span slot="suffix">MB</span>
        </cv-number>
      </div>

      <div>
        <span>Validation</span>
        <cv-field required invalid>
          <span slot="label">Age gate</span>
          <cv-number required invalid min="18" max="120" value="16"></cv-number>
          <span slot="error">Value must be at least 18.</span>
        </cv-field>
      </div>

      <div>
        <span>Clear defaults</span>
        <cv-number value="42" default-value="10" min="0" max="100" clearable stepper></cv-number>
        <cv-number value="10" default-value="10" min="0" max="100" clearable></cv-number>
      </div>

      <div>
        <span>Read state</span>
        <cv-number read-only value="100"></cv-number>
        <cv-field disabled>
          <span slot="label">Disabled by field</span>
          <cv-number value="50" stepper></cv-number>
        </cv-field>
      </div>
    </div>
  </section>
</div>

<script type="module">
  document.querySelectorAll('.number-demo-shell[data-demo="number"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'

    const form = shell.querySelector('[data-number-form]')
    const output = shell.querySelector('[data-number-output]')
    const mirror = shell.querySelector('[data-number-mirror]')
    const active = shell.querySelector('[data-number-active]')
    const primary = shell.querySelector('[data-number-primary]')
    const eventLabels = {
      'cv-change': 'change',
      'cv-clear': 'clear',
      'cv-focus': 'focus',
      'cv-blur': 'blur',
    }

    const readNumber = (number) => {
      if (!number) return 0
      if (typeof number.getValue === 'function') return number.getValue()
      return Number(number.value || number.getAttribute('value') || 0)
    }

    const syncMirror = () => {
      if (!mirror) return
      mirror.textContent = `${readNumber(primary)} GB`
    }

    const setOutput = (message) => {
      if (output) output.textContent = message
    }

    const getFieldName = (number) => {
      const label = number.closest('cv-field')?.querySelector('[slot="label"]')?.textContent?.trim()
      return label || number.getAttribute('name') || 'number'
    }

    const report = (event) => {
      const number = event.target instanceof HTMLElement ? event.target : null
      if (!number?.matches('cv-number')) return

      const name = getFieldName(number)
      const label = eventLabels[event.type] ?? event.type
      const value =
        event instanceof CustomEvent && event.detail && 'value' in event.detail
          ? event.detail.value
          : readNumber(number)

      if (active) active.textContent = `${label}: ${name}`
      setOutput(`${label}: ${name} -> ${value}`)
      syncMirror()
    }

    shell.querySelectorAll('[data-number-action]').forEach((button) => {
      button.addEventListener('click', () => {
        const action = button.getAttribute('data-number-action')
        if (!primary) return

        if (action === 'step-up') {
          primary.stepUp()
          setOutput(`action: Step up -> ${readNumber(primary)}`)
        } else if (action === 'page-up') {
          primary.pageUp()
          setOutput(`action: Page up -> ${readNumber(primary)}`)
        } else if (action === 'reset') {
          primary.setValue(30)
          setOutput('action: Reset -> 30')
        } else if (action === 'validate') {
          const valid = primary.reportValidity()
          setOutput(`validation: ${valid ? 'valid' : 'invalid'} at ${readNumber(primary)}`)
        }

        if (active) active.textContent = `action: ${action}`
        syncMirror()
      })
    })

    form?.addEventListener('submit', (event) => {
      event.preventDefault()
      setOutput(`submit: quota=${readNumber(primary)}`)
    })

    Object.keys(eventLabels).forEach((eventName) => {
      shell.addEventListener(eventName, report)
    })

    syncMirror()
  })
</script>

Anatomy

<cv-number> (host)
└── <div part="base">
    ├── <span part="prefix">
    │   └── <slot name="prefix">
    ├── <input part="input" role="spinbutton" inputmode="decimal">
    ├── <span part="clear-button" role="button">       ← conditional on showClearButton
    │   └── <slot name="clear-icon">×</slot>
    ├── <span part="stepper">                           ← conditional on stepper, horizontal controls
    │   ├── <button part="increment" type="button">
    │   └── <button part="decrement" type="button">
    └── <span part="suffix">
        └── <slot name="suffix">

Attributes

AttributeTypeDefaultReflectsDescription
valueNumber0noCurrent numeric value
default-valueNumbermin ?? 0noValue to reset to on clear; form reset restores the initial connected value snapshot
minNumbernoOptional minimum boundary
maxNumbernoOptional maximum boundary
stepNumber1noSmall increment/decrement step
large-stepNumber10noLarge increment/decrement step (PageUp/PageDown)
nameString""noForm field name for submit serialization
disabledBooleanfalseyesPrevents interaction and dims the component
read-onlyBooleanfalseyesKeeps focusable/announced but blocks user mutation
requiredBooleanfalseyesMarks the field as required for form validation
clearableBooleanfalseyesShows a clear button when the value differs from default
stepperBooleanfalseyesShows increment/decrement stepper buttons
placeholderString""noPlaceholder text displayed when the input is empty
sizeString"medium"yesComponent size: small | medium | large
variantString"outlined"yesVisual variant: outlined | filled
aria-labelString""noAccessible label
aria-labelledbyString""noID reference to visible label
aria-describedbyString""noID reference to description

Variants

VariantDescription
outlinedDefault style with visible border and transparent background
filledSubtle background fill with no visible border

Sizes

Size--cv-number-height--cv-number-padding-inline--cv-number-font-size
small30pxvar(--cv-space-2, 8px)var(--cv-font-size-sm, 13px)
medium36pxvar(--cv-space-3, 12px)var(--cv-font-size-base, 14px)
large42pxvar(--cv-space-4, 16px)var(--cv-font-size-md, 16px)

Slots

SlotDescription
prefixContent rendered before the input (e.g., currency symbol icon)
suffixContent rendered after the stepper controls (e.g., unit label)
clear-iconCustom icon for the clear button (default: ×)

Note: The native <input> element is not slottable. There is no default slot.

CSS Parts

PartElementDescription
base<div>Outermost wrapper element containing input and controls
input<input>The native input element with role="spinbutton"
prefix<span>Wrapper around the prefix slot
suffix<span>Wrapper around the suffix slot
clear-button<span>The clear button wrapper (conditionally visible)
stepper<span>Wrapper around increment/decrement buttons (conditionally visible)
increment<button>Increment stepper button
decrement<button>Decrement stepper button

CSS Custom Properties

PropertyDefaultDescription
--cv-number-height36pxComponent block size
--cv-number-padding-inlinevar(--cv-space-3, 12px)Horizontal padding inside the input container
--cv-number-font-sizevar(--cv-font-size-base, 14px)Font size of the input text
--cv-number-border-radiusvar(--cv-radius-sm, 6px)Border radius of the input container
--cv-number-border-colorvar(--cv-color-border, #2a3245)Border color in default state
--cv-number-backgroundtransparentBackground color of the input container
--cv-number-colorvar(--cv-color-text, #e8ecf6)Text color of the input value
--cv-number-placeholder-colorvar(--cv-color-text-muted, #6b7a99)Placeholder text color
--cv-number-focus-ring0 0 0 2px var(--cv-color-primary, #65d7ff)Box-shadow applied on focus
--cv-number-icon-size1emSize of prefix/suffix/clear icons
--cv-number-gapvar(--cv-space-2, 8px)Spacing between inner elements (prefix, input, buttons, suffix)
--cv-number-transition-durationvar(--cv-duration-fast, 120ms)Transition duration for state changes
--cv-number-stepper-width28pxLegacy fallback for horizontal stepper button inline size
--cv-number-stepper-button-inline-sizevar(--cv-number-stepper-width, 28px)Inline size of each horizontal stepper button
--cv-number-stepper-button-gap2pxGap between horizontal stepper buttons

Additionally, component styles depend on theme tokens through fallback values:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Base border color
--cv-color-surface#141923Surface background color (used by filled variant)
--cv-color-surface-elevated#1d2432Stepper button background
--cv-color-text#e8ecf6Default text color
--cv-color-text-muted#6b7a99Muted text color for placeholder
--cv-color-primary#65d7ffPrimary accent color for focus ring
--cv-duration-fast120msTransition duration
--cv-easing-standardeaseTransition timing function
--cv-radius-sm6pxBase border radius fallback
--cv-font-size-sm13pxSmall font size
--cv-font-size-base14pxBase font size
--cv-font-size-md16pxMedium font size
--cv-space-28pxSpacing scale: small
--cv-space-312pxSpacing scale: medium
--cv-space-416pxSpacing scale: large

Visual States

Host selectorDescription
:host([disabled])Reduced opacity (0.55), cursor: not-allowed, no interaction
:host([read-only])Normal opacity, cursor: default, input text not editable
:host([required])No visual change by default (can be styled via part selectors)
:host([focused])Focus ring applied via --cv-number-focus-ring
:host([filled])Indicates value differs from default (e.g., for floating label transitions)
:host([clearable])Clear button space reserved in layout
:host([stepper])Stepper buttons rendered and visible
:host([stepper-active="increment"])Transient pressed/step feedback for the increment button
:host([stepper-active="decrement"])Transient pressed/step feedback for the decrement button
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides
:host([variant="outlined"])Visible border, transparent background
:host([variant="filled"])Subtle background (--cv-color-surface), no visible border

Reactive State Mapping

cv-number is a visual adapter over headless createNumber.

UIKit properties to headless actions

UIKit PropertyDirectionHeadless Binding
valueattr/prop -> actionactions.setValue(value)
disabledattr -> actionactions.setDisabled(value)
read-onlyattr -> actionactions.setReadOnly(value)
requiredattr -> actionactions.setRequired(value)
placeholderattr -> actionactions.setPlaceholder(value)
clearableattr -> actionactions.setClearable(value)
stepperattr -> actionactions.setStepper(value)
minattr -> optionpassed to createNumber(options)
maxattr -> optionpassed to createNumber(options)
stepattr -> optionpassed to createNumber(options)
large-stepattr -> optionpassed to createNumber(options)
default-valueattr -> optionpassed as defaultValue to createNumber(options)
aria-labelattr -> optionpassed as ariaLabel to createNumber(options)
aria-labelledbyattr -> optionpassed as ariaLabelledBy to createNumber(options)
aria-describedbyattr -> optionpassed as ariaDescribedBy to createNumber(options)

Headless state to DOM reflection

Headless StateDirectionDOM Reflection
state.isDisabled()state -> attr[disabled] host attribute
state.isReadOnly()state -> attr[read-only] host attribute
state.required()state -> attr[required] host attribute
state.focused()state -> attr[focused] host attribute
state.filled()state -> attr[filled] host attribute
state.showClearButton()state -> DOMshows/hides the clear button element
state.stepper()state -> DOMshows/hides the stepper buttons
state.draftText()state -> DOMwhen non-null, displayed in the input; when null, displays formatted String(value)
state.value()state -> DOMdisplayed in the input when draftText is null
state.placeholder()state -> DOMapplied as placeholder on the native input
state.hasMin() / state.hasMax()state -> DOMfor conditional styling or rendering hints

Contract props spreading

  • contracts.getInputProps() is spread onto the [part="input"] native <input> element to apply id, role, tabindex, inputmode, aria-valuenow, aria-valuemin, aria-valuemax, aria-valuetext, aria-disabled, aria-readonly, aria-required, aria-label, aria-labelledby, aria-describedby, placeholder, and autocomplete.
  • contracts.getIncrementButtonProps() is spread onto the [part="increment"] button to apply id, tabindex, aria-label, aria-disabled, hidden, aria-hidden, and onClick.
  • contracts.getDecrementButtonProps() is spread onto the [part="decrement"] button to apply id, tabindex, aria-label, aria-disabled, hidden, aria-hidden, and onClick.
  • contracts.getClearButtonProps() is spread onto the [part="clear-button"] element to apply role, aria-label, tabindex, hidden, aria-hidden, and onClick.

Event wiring

  • Native <input> input event -> actions.handleInput(e.target.value) (updates draft text)
  • Native <input> keydown event -> actions.handleKeyDown(e) (handles ArrowUp/Down, PageUp/Down, Home/End, Enter, Escape)
  • Native <input> focus event -> actions.setFocused(true) -> dispatches cv-focus CustomEvent
  • Native <input> blur event -> actions.setFocused(false) (triggers draft commit) -> dispatches cv-blur CustomEvent; if value changed since focus, dispatches cv-change CustomEvent
  • Clear button click -> actions.clear() -> dispatches cv-clear CustomEvent
  • Increment/decrement button click -> unified user step path -> actions.increment() / actions.decrement() -> dispatches cv-change CustomEvent only when the committed value changes
  • Focused stepper + fine-pointer desktop wheel up/down -> normalized threshold accumulator -> unified user step path; modifiers, horizontal-dominant wheel movement, unfocused controls, disabled/read-only controls, and hidden steppers are ignored
  • stepper + horizontal touch swipe right/left -> thresholded scrub steps through the unified user step path; vertical-dominant touch movement cancels the gesture so page scroll remains available
  • Stepper button press-and-hold -> delayed repeated steps through the unified user step path, with accelerating repeat interval and click suppression after repeat starts
  • Successful touch-origin swipe and long-press steps may call navigator.vibrate(6) as best-effort feedback when available; vibration is throttled and disabled under prefers-reduced-motion: reduce

Input display logic (UIKit responsibility)

UIKit reads state.draftText() and state.value() to determine what to display in the native <input>:

  • When draftText !== null: display draftText (user is actively editing)
  • When draftText === null: display formatted String(value) (committed state)

UIKit does not own value management, clamping, snapping, draft commit logic, or ARIA computation; headless state is the source of truth.

Events

EventDetailDescription
cv-change{ value: number }Fires on committed value change from user interaction (stepper click, focused wheel step, touch swipe step, long-press step, keyboard step, draft commit on blur/Enter). Does not fire from programmatic setValue.
cv-clear{ }Fires when the value is cleared via the clear button or Escape key
cv-focus{ }Fires when the input receives focus
cv-blur{ }Fires when the input loses focus

Imperative API

Method / PropertyDescription
stepUp(times = 1)Increments by step times times
stepDown(times = 1)Decrements by step times times
pageUp(times = 1)Increments by largeStep times times
pageDown(times = 1)Decrements by largeStep times times
setValue(value)Sets numeric value through headless normalization
getValue()Returns current committed numeric value
setRange(min, max)Updates range boundaries; null/undefined removes a bound
focus(options?)Focuses inner input control
select()Selects text in inner input control
checkValidity()Runs current validation checks
reportValidity()Reports validation state to UA when supported
setCustomValidity(message)Sets/clears custom validity message
formForm owner when form-associated internals are supported
validityCurrent validity state when supported
validationMessageCurrent validation message
willValidateWhether control participates in validation

Form Association

  • Component is form-associated via ElementInternals when available.
  • Submit value is serialized as the committed numeric value string.
  • disabled state removes form value from submission.
  • Reset restores the initial value snapshot captured on first connection.
  • Clear button and Escape reset to default-value (min ?? 0 when omitted), which is separate from form reset.
  • Browser form-state restoration accepts numeric string state and ignores non-numeric state.

ChromVoid UIKit documentation