Step, scale, format and read numbers in someone's language, with one keyboard map for numeric controls.

@ui-sdk/core/number provides steps and bounds, scales, formatting, parsing and the keyboard map numeric controls share.

The helpers are plain functions that run without a DOM. Slider, Number Input, Progress and Meter use the same helpers, so a control of your own steps, formats and reads numbers the way they do.

Bounds and Steps

Snap to a Step

scrubTo puts a value on the step grid within the bounds, and stepValue moves a number of steps from it. Both round away floating point noise, so 0.1 + 0.2 steps land on 0.3:

import { clamp, roundTo, scrubTo, stepValue } from "@ui-sdk/core/number"

const range = { min: 0, max: 10, step: 0.25 }

scrubTo(3.3, range).value // 3.25
stepValue(3.3, 1, range) // 3.5, off the grid moves onto it first
stepValue(9.9, 1, range) // 10
clamp(14, 0, 10) // 10
roundTo(0.1 + 0.2, 2) // 0.3

The grid starts at min when it's finite, like a native range input, so { min: 1, max: 10, step: 2 } steps through 1, 3, 5, 7 and 9. maxOnGrid returns that highest value, and decimalsOf counts a number's decimals. Give the range snap points and scrubTo lands on those too, as scrubbing shows. A step of 0 has no grid, so values stay exactly as they are, within the bounds.

Loops

wrap keeps a value in a loop, for hues, angles, clock faces and carousels that go round:

import { wrap } from "@ui-sdk/core/number"

wrap(370, 0, 360) // 10
wrap(-15, 0, 360) // 345
wrap(slide + 1, 0, slides.length) // the next slide, back to the first after the last

Keys

One Keyboard Map

stepFromKey reads a key into a move, as the APG's slider and spinbutton patterns describe: Up and Right step up, Down and Left step down, Shift takes the large step and Alt the small one, Page Up and Down take the large step, and Home and End go to the bounds. stepBy applies the move:

import { stepBy, stepFromKey } from "@ui-sdk/core/number"

const range = { min: 0, max: 20, step: 0.5, largeStep: 5, smallStep: 0.1 }

function onKeyDown(event: KeyboardEvent) {
  const move = stepFromKey(event, { arrows: "vertical" })
  if (!move) return
  event.preventDefault()
  value = stepBy(value, move, range)
}
import * as React from "react"
import {
  formatNumber,
  parseNumber,
  scrubTo,
  stepBy,
  stepFromKey,
} from "@ui-sdk/core/number"
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldLabel,
} from "@ui-sdk/react/field"

const range = { min: 0, max: 20, step: 0.5, largeStep: 5, smallStep: 0.1 }

function Example() {
  const [value, setValue] = React.useState(2.5)
  const [typed, setTyped] = React.useState<string | null>(null)
  const shown = formatNumber(value, "en-US", { maximumFractionDigits: 1 })
  const current = () => {
    const parsed = typed === null ? null : parseNumber(typed, "en-US")
    return parsed === null ? value : scrubTo(parsed, range).value
  }
  const commit = () => {
    setValue(current())
    setTyped(null)
  }
  return (
    <Field>
      <FieldLabel>Quantity in kg</FieldLabel>
      <FieldControl
        role="spinbutton"
        inputMode="decimal"
        autoComplete="off"
        aria-valuemin={range.min}
        aria-valuemax={range.max}
        aria-valuenow={value}
        aria-valuetext={`${shown} kg`}
        value={typed ?? shown}
        onChange={(event) => setTyped(event.target.value)}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === "Enter") {
            return commit()
          }
          const move = stepFromKey(event, { arrows: "vertical" })
          if (!move) {
            return
          }
          event.preventDefault()
          setValue(stepBy(current(), move, range))
          setTyped(null)
        }}
      />
      <FieldDescription>
        Arrows step by 0.5, Shift by 5 and Alt by 0.1. Home and End go to 0 and
        20.
      </FieldDescription>
    </Field>
  )
}

export { Example }
Focus the quantity and press the arrows, with Shift or Alt for bigger and smaller steps, then Home and End. Type a weight and press Enter: it snaps to the step.

Right to left flips Left and Right on a horizontal control, as { dir: "rtl" }; a vertical one keeps them. { arrows: "vertical" } leaves Left and Right to a text caret, as a number field does. Without largeStep, a large step is ten steps; without smallStep, a small step is one. While an input method composes, like a Japanese one typing full-width digits, stepFromKey returns null, so an arrow that picks a candidate doesn't also step.

A move is { direction, size } or { bound }, so a control can treat a bound its own way, like a slider's thumb that stops at its neighbor.

Positions and Scales

Value to Position

fractionOf gives where a value sits between bounds, from 0 to 1, and valueAt gives the value at a position. Together they place a thumb, fill a bar and turn a press into a value:

import { fractionOf, scrubTo, valueAt } from "@ui-sdk/core/number"

fractionOf(30, { min: 0, max: 120 }) // 0.25
const pressed = valueAt((event.clientX - box.left) / box.width, range)
scrubTo(pressed, range).value

Values outside the bounds sit at an end, and bounds that are the same give 0.

Log and Custom Scales

On a log scale, each tenfold takes the same distance, as frequency, volume and zoom controls need. Pass "log", with a positive min, or a to and from pair that maps values into an even space and back:

fractionOf(200, { min: 20, max: 20000 }, "log") // 0.33: 20 to 200 is a third of the way
valueAt(0.5, { min: 20, max: 20000 }, "log") // 632, halfway by ratio

const area = { to: Math.sqrt, from: (position: number) => position * position }
fractionOf(25, { min: 0, max: 100 }, area) // 0.5: a size that feels even by area, like a brush
import * as React from "react"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
} from "@ui-sdk/react/segmented-control"
import { formatNumber, fractionOf, valueAt } from "@ui-sdk/core/number"

const range = { min: 1, max: 180 }

function Example() {
  const [scale, setScale] = React.useState<"linear" | "log">("log")
  const [angle, setAngle] = React.useState(45)
  const position = fractionOf(angle, range, scale)
  const degrees = `${formatNumber(angle, "en-US", { maximumFractionDigits: 0 })}°`
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <SegmentedControl
          aria-label="Scale"
          value={scale}
          onValueChange={(next) => next && setScale(next as "linear" | "log")}
        >
          <SegmentedControlIndicator />
          <SegmentedControlItem value="linear">Linear</SegmentedControlItem>
          <SegmentedControlItem value="log">Log</SegmentedControlItem>
        </SegmentedControl>
      </div>
      <div aria-hidden="true" data-slot="number-scale-dial">
        <div
          data-needle
          data-slot="number-scale-needle"
          style={{ rotate: `${-angle}deg` }}
        />
      </div>
      <Field>
        <div data-slot="row">
          <FieldLabel>Rotation</FieldLabel>
          <span data-slot="hint">
            <span data-angle>{degrees}</span>,{" "}
            <span data-position>{Math.round(position * 100)}% along</span>
          </span>
        </div>
        <FieldControl
          type="range"
          aria-valuetext={degrees}
          min={0}
          max={1}
          step={0.001}
          value={position}
          onChange={(event) =>
            setAngle(valueAt(Number(event.target.value), range, scale))
          }
        />
      </Field>
    </div>
  )
}

export { Example }
Drag the slider to turn the needle from 1° to 180°, then switch between Linear and Log. On the log scale, small angles get most of the slider: 45° sits 73% along, and halfway turns it only 13°.

To step a scaled control evenly, step its position and read the value back, like valueAt(stepBy(fraction, move, { min: 0, max: 1, step: 0.01 }), range, "log").

Scrubbing

Snap While Scrubbing

Give a range snap points and a threshold, and a scrub lands on a point when it comes that close, like a timeline's chapters or an editor's keyframes. scrubTo reads a pointer or a slider, and stepBy takes the same points for keys: a step stops at the first point it reaches ahead, and leaves the point it's on freely:

import { scrubTo, stepBy, stepFromKey } from "@ui-sdk/core/number"

const range = {
  min: 0,
  max: 60,
  step: 0.1,
  snap: { points: [0, 9.5, 24, 38.5, 52], threshold: 1.5 },
}

scrubTo(23, range) // { value: 24, point: 24 }
scrubTo(23, range, { snap: false }) // { value: 23, point: undefined }

function onKeyDown(event: KeyboardEvent) {
  const move = stepFromKey(event)
  if (!move) return
  event.preventDefault()
  time = stepBy(time, move, range, { snap: !event.metaKey })
}

{ snap: false } ignores the points, like while Cmd is held or for a typed number, and still lands on the step grid. A slider's input event doesn't say which keys are down, so read them from its pointer events:

<input
  type="range"
  onPointerDown={(event) => (free = event.metaKey)}
  onPointerMove={(event) => (free = event.metaKey)}
  onChange={(event) =>
    setTime(scrubTo(Number(event.target.value), range, { snap: !free }).value)
  }
/>
import * as React from "react"
import {
  formatNumber,
  fractionOf,
  scrubTo,
  stepBy,
  stepFromKey,
} from "@ui-sdk/core/number"

const sections = [
  { at: 0, label: "Intro" },
  { at: 9.5, label: "Verse" },
  { at: 24, label: "Chorus" },
  { at: 38.5, label: "Bridge" },
  { at: 52, label: "Outro" },
]

const range = {
  min: 0,
  max: 60,
  step: 0.1,
  largeStep: 5,
  snap: { points: sections.map((section) => section.at), threshold: 1.5 },
}

const clock = (time: number) =>
  `${Math.floor(time / 60)}:${formatNumber(time % 60, "en-US", {
    minimumIntegerDigits: 2,
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  })}`

function Example() {
  const [time, setTime] = React.useState(17)
  const free = React.useRef(false)
  const section = sections.find((candidate) => candidate.at === time)
  const track = (event: React.PointerEvent) => {
    free.current = event.metaKey || event.ctrlKey
  }
  return (
    <div data-slot="scrubber">
      <div data-slot="scrubber-track">
        {sections.map((candidate) => (
          <div
            key={candidate.label}
            data-slot="scrubber-section"
            data-active={candidate === section || undefined}
            style={{ left: `${fractionOf(candidate.at, range) * 100}%` }}
          >
            {candidate.label}
          </div>
        ))}
        <div
          data-slot="scrubber-playhead"
          data-playhead
          style={{ left: `${fractionOf(time, range) * 100}%` }}
        />
      </div>
      <input
        type="range"
        aria-label="Playhead"
        aria-valuetext={
          section ? `${clock(time)}, ${section.label}` : clock(time)
        }
        min={range.min}
        max={range.max}
        step={range.step}
        value={time}
        onPointerDown={track}
        onPointerMove={track}
        onChange={(event) =>
          setTime(
            scrubTo(Number(event.target.value), range, { snap: !free.current })
              .value
          )
        }
        onKeyDown={(event) => {
          const move = stepFromKey(event)
          if (!move) {
            return
          }
          event.preventDefault()
          setTime(
            stepBy(time, move, range, {
              snap: !(event.metaKey || event.ctrlKey),
            })
          )
        }}
      />
      <p data-slot="scrubber-readout">
        <span data-time>{clock(time)}</span>
        <span data-section>{section?.label ?? "Between sections"}</span>
      </p>
      <p data-slot="hint">Hold ⌘ or Ctrl to scrub freely.</p>
    </div>
  )
}

export { Example }
Drag the slider: the playhead snaps to each section as it comes within 1.5 seconds, and arrow keys stop at each one. Hold Command or Ctrl to scrub and step freely.

point says which snap point a value is pulled to, so a release can commit point ?? value. With { ease: true }, a value near a point moves toward it instead of jumping: not at all at the threshold, faster as it closes in, and onto the point at the end. Show value while dragging, and land on point:

scrubTo(23.25, range, { ease: true }) // { value: 23.625, point: 24 }
scrubTo(22.5, range, { ease: true }) // { value: 22.5, point: 24 }, at the threshold

Pass a curve over closeness, from 0 at the threshold to 1 on the point, like { ease: (t) => t * t }, to change how the pull builds. Collapse Past a Threshold eases a sidebar into collapsing.

A control that leaves Up and Down to the page, like a timeline in a list of tracks, passes { arrows: "horizontal" } to stepFromKey.

Formatting

Numbers in Any Language

formatNumber shows a value through Intl.NumberFormat in a locale: currencies, percents, units and compact counts, with the locale's own digits and separators. Pass a function for text of your own. Null and values that aren't finite show as empty text:

import { formatNumber } from "@ui-sdk/core/number"

formatNumber(1234.5, "de-DE", { style: "currency", currency: "EUR" }) // "1.234,50 €"
formatNumber(1234.5, "ja-JP", { style: "currency", currency: "JPY" }) // "¥1,235"
formatNumber(1234.5, "zh-CN", { style: "currency", currency: "CNY" }) // "¥1,234.50"
formatNumber(0.256, "en-US", { style: "percent", maximumFractionDigits: 1 }) // "25.6%"
formatNumber(1234567, "en-US", { notation: "compact" }) // "1.2M"
formatNumber(3, "en-US", (value) => `${value} of 5 stars`) // "3 of 5 stars"

Use the same text for a control's aria-valuetext, so screen readers hear what's shown. numberFormatter returns the Intl.NumberFormat itself, built once per locale and options.

Ranges and Parts

formatNumberRange writes what both ends share once, and formatNumberToParts returns each digit group and symbol, to style them apart or roll digits like an odometer:

import { formatNumberRange, formatNumberToParts } from "@ui-sdk/core/number"

formatNumberRange(5, 10, "en-US", { style: "currency", currency: "USD" }) // "$5.00 – $10.00"
formatNumberToParts(1234.5, "en-US", { style: "currency", currency: "USD" })
// [{ type: "currency", value: "$" }, { type: "integer", value: "1" }, { type: "group", value: "," }, …]
import * as React from "react"
import {
  formatNumber,
  formatNumberRange,
  formatNumberToParts,
} from "@ui-sdk/core/number"

const locales = ["en-US", "de-DE", "ja-JP", "zh-CN", "ar-EG", "hi-IN"]
const styles: Record<string, Intl.NumberFormatOptions> = {
  Currency: { style: "currency", currency: "EUR" },
  Percent: { style: "percent", maximumFractionDigits: 1 },
  Compact: { notation: "compact" },
  Unit: { style: "unit", unit: "kilometer-per-hour" },
}
const values: Record<string, [number, number]> = {
  Currency: [1234.5, 2500],
  Percent: [0.256, 0.5],
  Compact: [1234567, 9800000],
  Unit: [88, 120],
}

function Example() {
  const [locale, setLocale] = React.useState("en-US")
  const [style, setStyle] = React.useState("Currency")
  const [value, end] = values[style]
  const format = styles[style]
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <select
          aria-label="Locale"
          value={locale}
          onChange={(event) => setLocale(event.target.value)}
        >
          {locales.map((option) => (
            <option key={option}>{option}</option>
          ))}
        </select>
        <select
          aria-label="Style"
          value={style}
          onChange={(event) => setStyle(event.target.value)}
        >
          {Object.keys(styles).map((option) => (
            <option key={option}>{option}</option>
          ))}
        </select>
      </div>
      <div data-slot="number-format-result">
        <p data-formatted data-slot="number-format-value">
          {formatNumberToParts(value, locale, format).map((part, index) => (
            <span key={index} data-type={part.type}>
              {part.value}
            </span>
          ))}
        </p>
        <dl>
          <dt>Plain</dt>
          <dd data-plain>{formatNumber(value, locale, format)}</dd>
          <dt>Range</dt>
          <dd data-range>{formatNumberRange(value, end, locale, format)}</dd>
        </dl>
      </div>
    </div>
  )
}

export { Example }
Pick a locale and a style. The large number is built from its parts, with the fraction and currency dimmed, and the range shares what both ends have.

Typing a Number

Read What People Type

parseNumber reads a number typed in a locale's format: its digits, group and decimal separators, minus signs, and currency, percent and unit text. It returns null for text that isn't a number, and percent formats divide by 100. isPartialNumber says whether text could still become one, so a field can wait on a lone "-" instead of rejecting it:

import { isPartialNumber, parseNumber } from "@ui-sdk/core/number"

parseNumber("1.234,5", "de-DE") // 1234.5
parseNumber("٣٫٥", "ar-EG") // 3.5
parseNumber("50 %", "fr-FR", { style: "percent" }) // 0.5
parseNumber("-", "de-DE") // null
isPartialNumber("-", "de-DE") // true
Console
import * as React from "react"
import { formatNumber, isPartialNumber, parseNumber } from "@ui-sdk/core/number"
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldLabel,
} from "@ui-sdk/react/field"
import { useLog } from "./log"

const locales = ["en-US", "de-DE", "fr-FR", "ar-EG"]

function Example() {
  const log = useLog()
  const [locale, setLocale] = React.useState("de-DE")
  const [text, setText] = React.useState("1.234,5")
  const value = parseNumber(text, locale)
  const typing = value === null && isPartialNumber(text, locale)
  const read = (next: string, nextLocale: string) => {
    const parsed = parseNumber(next, nextLocale)
    const verdict =
      parsed !== null
        ? String(parsed)
        : isPartialNumber(next, nextLocale)
          ? "keep typing"
          : "not a number"
    log(`“${next}” in ${nextLocale}: ${verdict}`)
  }
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <select
          aria-label="Locale"
          value={locale}
          onChange={(event) => {
            setLocale(event.target.value)
            read(text, event.target.value)
          }}
        >
          {locales.map((option) => (
            <option key={option}>{option}</option>
          ))}
        </select>
      </div>
      <Field>
        <FieldLabel>Amount</FieldLabel>
        <FieldControl
          inputMode="decimal"
          value={text}
          aria-invalid={value === null && !typing}
          onChange={(event) => {
            setText(event.target.value)
            read(event.target.value, locale)
          }}
        />
        <FieldDescription>Like {formatNumber(9876.5, locale)}</FieldDescription>
      </Field>
    </div>
  )
}

export { Example }
Type an amount the way the locale writes it, or switch the locale. A number that's on its way waits; text that can't become one is marked.

API Reference

All of these functions come from @ui-sdk/core/number.

You Want ToUse
Keep a value on a step grid within boundsscrubTo, stepValue, clamp, roundTo, decimalsOf, maxOnGrid
Keep a value in a loopwrap
Turn keys into stepsstepFromKey, stepBy
Map values to positions and backfractionOf, valueAt
Snap a scrub to pointsscrubTo, stepBy
Show numbers in someone's languageformatNumber, formatNumberRange, formatNumberToParts, numberFormatter
Read typed numbersparseNumber, isPartialNumber