Number input - headless

createNumberInput drives SvNumberInput and a custom stepper; parse, clamp, spinner and keyboard all from the core.

A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.

About this example

The headless number input core of the SvGrid UI kit in Svelte 5. SvNumberInput is a styled renderer over createNumberInput, which owns parsing, formatting with grouping and precision, clamping to min and max, the step spinner and the keyboard, exposed as inputProps, incrementProps and decrementProps. The same core drives the styled component and a custom stepper render, both bound to one number.

Headless number input - <SvNumberInput> is just a styled renderer over the framework-free-in-spirit createNumberInput core: parse / format / clamp, the spinner and full keyboard, all exposed as **prop-getters** you spread onto YOUR OWN markup. Below, the same core drives the styled component AND a totally custom stepper render, both bound to one value. No forked logic.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

What does the core do with typed text?

It parses the text according to grouping and precision, clamps to min and max on commit and formats the display; your render only spreads inputProps() and shows the formatted string.

How do the spinner buttons work?

incrementProps() and decrementProps() wire click and hold-to-repeat with the step, and disable themselves at the bounds.

Which keys are handled?

Up and Down step by step, Enter commits the parsed value, and the spinner buttons repeat while held; the core clamps every result to min and max.

Related documentation

Related articles

  • SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
  • SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
  • SvGrid UI Components Tips and Tricks: Svelte 5 Buttons, Inputs, Tree, Forms - Practical tips for the SvGrid UI component kit - SvButton states, a Cmd+K command palette, a virtualized searchable tree, schema-driven forms, a parsing date/time field, and the shared field contract - each with a code snippet.

Source code (266-headless-numberinput.svelte)

<script lang="ts">
  /**
   * Headless number input - <SvNumberInput> is just a styled renderer over the
   * framework-free-in-spirit `createNumberInput` core: parse / format / clamp,
   * the spinner and full keyboard, all exposed as **prop-getters** you spread
   * onto YOUR OWN markup. Below, the same core drives the styled component AND a
   * totally custom stepper render, both bound to one value. No forked logic.
   */
  import { SvNumberInput, createNumberInput } from '@svgrid/grid'

  let value = $state<number | null>(1250)

  // The headless core - identical behavior, our own DOM.
  const num = createNumberInput({
    value: () => value,
    onChange: (v) => (value = v),
    min: () => 0,
    max: () => 9999,
    step: () => 50,
    grouping: () => true,
    prefix: () => '$',
  })
</script>

<div class="wrap">
  <header>
    <h2>Headless number input</h2>
    <p>
      Every editor ships a headless core + a styled component - the same split as the grid
      (<code>createSvGrid</code> / <code>&lt;SvGrid&gt;</code>). Here <code>createNumberInput</code>
      drives both renders below; parsing, clamping, the spinner and keyboard come from the core, the markup is yours.
    </p>
  </header>

  <div class="cols">
    <section>
      <h3>Styled <code>&lt;SvNumberInput&gt;</code></h3>
      <SvNumberInput {value} onChange={(v) => (value = v)} min={0} max={9999} step={50} grouping prefix="$" ariaLabel="Budget" />
    </section>

    <section>
      <h3>Your markup, same core</h3>
      <div class="stepper">
        <button class="step" {...num.decrementProps()}>-</button>
        <input class="field" {...num.inputProps()} />
        <button class="step" {...num.incrementProps()}>+</button>
      </div>
      <p class="hint">Arrow keys and the +/- buttons step by 50, blur re-formats with grouping - all from <code>createNumberInput</code>.</p>
    </section>
  </div>

  <aside class="readout">
    <h3>Value</h3>
    {#if value != null}
      <div class="tags"><span class="tag">{value}</span></div>
    {:else}
      <p class="empty">Empty</p>
    {/if}
  </aside>
</div>

<style>
  .wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
  code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
  .cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
  section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  /* Fully custom stepper render - nothing shared with SvNumberInput but the core. */
  .stepper { display: inline-flex; align-items: stretch; gap: 6px; }
  .field {
    width: 120px; text-align: center; font: inherit; font-size: 14px; padding: 0 10px; height: 38px;
    border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
  }
  .field:focus-visible { border-color: var(--sg-accent, #4f46e5); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-accent, #4f46e5) 30%, transparent); }
  .step {
    width: 38px; height: 38px; border-radius: 10px; font-size: 18px; font-weight: 600; cursor: pointer;
    border: 1px solid var(--sg-border, #e2e8f0); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
  }
  .step:hover:not(:disabled) { border-color: var(--sg-accent, #4f46e5); color: var(--sg-accent, #4f46e5); }
  .step:disabled { opacity: 0.4; cursor: not-allowed; }
  .hint { margin: 10px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
  .readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  .tags { display: flex; gap: 6px; flex-wrap: wrap; }
  .tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); font-variant-numeric: tabular-nums; }
  .empty { color: var(--sg-muted, #94a3b8); font-style: italic; margin: 0; }
</style>

View this example on GitHub

More Headless Editors examples

  • Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
  • Combobox - headless - createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.
  • Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
  • Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
  • Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.